Skip to content

Commit 4787943

Browse files
authored
Merge pull request #1316 from stokpop/issue-1301-replace-eval-opts-expansion
fix: replace eval-based start command with shell-free javaexec launcher (#1301)
2 parents eed10a7 + de38d37 commit 4787943

32 files changed

Lines changed: 1395 additions & 95 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ tmp/
1515
.cache/
1616
bin/detect
1717
bin/finalize
18+
bin/javaexec
1819
bin/release
1920
bin/supply
2021
/*.md

bin/finalize

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ output_dir=$(mktemp -d -t finalizeXXX)
1414
pushd $BUILDPACK_DIR > /dev/null
1515
echo "-----> Running go build finalize"
1616
GOROOT=$GoInstallDir $GoInstallDir/bin/go build -mod=vendor -o $output_dir/finalize ./src/java/finalize/cli
17+
echo "-----> Running go build javaexec"
18+
GOROOT=$GoInstallDir $GoInstallDir/bin/go build -mod=vendor -o $output_dir/javaexec ./src/java/javaexec/cli
1719
popd > /dev/null
1820

19-
$output_dir/finalize "$BUILD_DIR" "$CACHE_DIR" "$DEPS_DIR" "$DEPS_IDX" "$PROFILE_DIR"
21+
JAVAEXEC_BINARY_PATH=$output_dir/javaexec $output_dir/finalize "$BUILD_DIR" "$CACHE_DIR" "$DEPS_DIR" "$DEPS_IDX" "$PROFILE_DIR"

docs/DEVELOPING.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ Build the buildpack binaries:
9797
This creates executables in the `bin/` directory:
9898
- `bin/supply` - Staging phase binary (downloads and installs dependencies)
9999
- `bin/finalize` - Finalization phase binary (configures runtime)
100+
- `bin/javaexec` - Shell-free JVM launcher (tokenizes `JAVA_OPTS` without `eval`)
100101

101102
## Project Structure
102103

@@ -118,6 +119,7 @@ java-buildpack/
118119
│ ├── jres/ # JRE implementations (7 providers)
119120
│ ├── supply/cli/ # Supply phase entrypoint
120121
│ ├── finalize/cli/ # Finalize phase entrypoint
122+
│ ├── javaexec/cli/ # Shell-free JVM launcher entrypoint
121123
│ ├── resources/ # Resource configuration files
122124
│ └── integration/ # Integration tests
123125
├── scripts/ # Build and test scripts
@@ -156,6 +158,7 @@ Build for the default platform (Linux):
156158
```
157159
-----> Building supply for linux
158160
-----> Building finalize for linux
161+
-----> Building javaexec for linux
159162
-----> Build complete
160163
```
161164

@@ -193,6 +196,25 @@ go build -mod vendor -o bin/supply src/java/supply/cli/main.go
193196

194197
# Build finalize
195198
go build -mod vendor -o bin/finalize src/java/finalize/cli/main.go
199+
200+
# Build javaexec (shell-free JVM launcher, required at runtime)
201+
go build -mod vendor -o bin/javaexec src/java/javaexec/cli/main.go
202+
```
203+
204+
### Source/Git Buildpack Usage
205+
206+
When deploying with a git URL (`cf push -b https://github.com/.../java-buildpack.git`),
207+
Cloud Foundry runs `bin/finalize` directly from the cloned source. In that mode
208+
`bin/javaexec` does not exist (only packaged buildpacks have it). `bin/finalize`
209+
therefore builds `javaexec` into a temp directory alongside the finalize binary
210+
and passes the path via the `JAVAEXEC_BINARY_PATH` environment variable.
211+
`InstallJavaexecLauncher()` prefers this override and falls back to
212+
`bin/javaexec` for packaged buildpacks.
213+
214+
To verify this path locally (no CF required):
215+
216+
```bash
217+
bash scripts/test-javaexec-source-path.sh
196218
```
197219

198220
## Running Tests

docs/design.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,12 @@ During the finalize phase (`bin/finalize`), the buildpack:
186186
1. **Finalizes JRE**: Configures JVM options, memory calculator
187187
2. **Finalizes Frameworks**: Adds agent paths, system properties
188188
3. **Finalizes Container**: Generates launch command
189+
4. **Installs `javaexec`**: Copies the shell-free JVM launcher into
190+
`$DEPS_DIR/<idx>/bin/javaexec`. The generated start command invokes it
191+
instead of `eval "exec java $JAVA_OPTS ..."` so that `JAVA_OPTS` is
192+
tokenized without shell interpretation. For source/git buildpack usage
193+
`bin/finalize` builds `javaexec` on the fly and passes its path via
194+
`JAVAEXEC_BINARY_PATH` (see [DEVELOPING.md](DEVELOPING.md#sourcegit-buildpack-usage)).
189195

190196
Components can:
191197
- Read installed dependencies from `$DEPS_DIR/<idx>/`

docs/framework-java_opts.md

Lines changed: 65 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -26,37 +26,83 @@ The framework can be configured by creating or modifying the [`config/java_opts.
2626

2727
Any `JAVA_OPTS` from either the config file or environment variables will be specified in the start command after any Java Opts added by other frameworks.
2828

29-
## Escaping strings
29+
## Runtime variable expansion
3030

31-
Java options will have special characters escaped when used in the shell command that starts the Java application but the `$` and `\` characters will not be escaped. This is to allow Java options to include environment variables when the application starts.
31+
Java options are assembled at container start by the buildpack's `profile.d` script
32+
(`00_java_opts.sh`), then passed to the JVM by the shell-free `javaexec` launcher.
33+
Because `javaexec` tokenizes `JAVA_OPTS` without invoking a shell, characters such as
34+
`*`, `&`, `;`, `|`, and `>` are treated as literals — they reach the JVM exactly as
35+
written.
3236

33-
```bash
34-
cf set-env my-application JAVA_OPTS '-Dexample.port=$PORT'
35-
```
36-
37-
If an escaped `$` or `\` character is needed in the Java options they will have to be escaped manually. For example, to obtain this output in the start command.
37+
### Environment variable references
3838

39-
```bash
40-
-Dexample.other=something.\$dollar.\\slash
41-
```
39+
`$VARNAME` and `${VARNAME}` references in **both** `JAVA_OPTS` (env) and `java_opts`
40+
(config) are expanded at container start against the runtime environment:
4241

43-
From the command line use;
4442
```bash
45-
cf set-env my-application JAVA_OPTS '-Dexample.other=something.\\\\\$dollar.\\\\\\\slash'
43+
# $PWD, $HOME, $PORT, and any CF-injected variable all work
44+
cf set-env my-application JAVA_OPTS '-Dapp.config=$PWD/config/app.properties'
45+
cf set-env my-application JAVA_OPTS '-Dserver.port=$PORT'
4646
```
4747

48-
From the [`config/java_opts.yml`][] file use;
4948
```yaml
50-
from_environment: true
51-
java_opts: '-Dexample.other=something.\\$dollar.\\\\slash'
49+
# config/java_opts.yml
50+
java_opts: '-Xloggc:$PWD/beacon_gc.log -verbose:gc'
5251
```
5352
54-
Finally, from the applications manifest use;
55-
```yaml
56-
env:
57-
JAVA_OPTS: '-Dexample.other=something.\\\\\$dollar.\\\\\\\slash'
53+
### Command substitutions are never executed
54+
55+
`$(...)` and backtick command substitutions are **not** executed. A value such as
56+
`-Dinject=$(hostname)` reaches the JVM as the literal string `-Dinject=$(hostname)`.
57+
This is intentional: executing arbitrary commands from a user-supplied option string
58+
would be a security vulnerability.
59+
60+
### Processor count: `$(nproc)`
61+
62+
The one exception is `-XX:ActiveProcessorCount=$(nproc)`, which the buildpack itself
63+
emits for JRE vendors that need it. The profile.d script resolves this single known
64+
token to the actual CPU count before passing the option to the JVM. Any other
65+
`$(...)` expression passes to the JVM literally.
66+
67+
### Special characters and quoting
68+
69+
Characters that were shell-special under the old `eval`-based launcher (`*`, `&`,
70+
`;`, `|`, `>`) are now passed to the JVM as literals — no quoting tricks required.
71+
72+
POSIX quoting in the assembled `JAVA_OPTS` string is respected by `javaexec`'s
73+
tokenizer: a quoted value such as `"-Dfoo=bar baz"` is delivered as the single
74+
argument `-Dfoo=bar baz`.
75+
76+
| Want to pass to JVM | Write in `JAVA_OPTS` / `java_opts` |
77+
|---------------------|-------------------------------------|
78+
| Literal `$PORT` (no expansion) | `\$PORT` |
79+
| Literal `\` backslash | `\\` |
80+
| Literal `\\` two backslashes | `\\\\` |
81+
| Value of `$PORT` at runtime | `$PORT` |
82+
| Cron expression `0 */7 * * *` | `0 */7 * * *` (no quoting needed) |
83+
| Space inside one JVM arg | `"-Dfoo=bar baz"` (quote the arg) |
84+
85+
```bash
86+
# Expand $PORT at runtime
87+
cf set-env my-application JAVA_OPTS '-Dserver.port=$PORT'
88+
89+
# Literal $PORT — not expanded
90+
cf set-env my-application JAVA_OPTS '-Dexample.literal=\$PORT'
91+
92+
# Windows-style path — \\ becomes one backslash
93+
cf set-env my-application JAVA_OPTS '-Dapp.data=C:\\data\\app'
94+
95+
# Cron expression — * is not glob-expanded
96+
cf set-env my-application JAVA_OPTS '-DcronExpr=0 */7 * * *'
5897
```
5998

99+
> **Note:** `$` followed by a digit or non-identifier character (e.g. `$1`, `$.`)
100+
> is left as-is. Undefined variables expand to an empty string.
101+
102+
> **Migrating from the Ruby buildpack?** See
103+
> [Migrating JAVA_OPTS escaping from the Ruby buildpack](java_opts-ruby-migration.md)
104+
> for a comparison of the escaping rules.
105+
60106
## Examples
61107

62108
### Configuration File Example

docs/framework-ordering.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,16 @@ This ensures:
9494
2. **Container Security Provider runs BEFORE JRebel** (07 < 20)
9595
3. **User JAVA_OPTS override everything** (99 runs last)
9696

97+
> **Note (safe expansion):** the snippet above is simplified. The real
98+
> `00_java_opts.sh` does **not** use `eval`. It expands only `$VAR` / `${VAR}`
99+
> references in `.opts` content via a pure-bash expander, so embedded command
100+
> substitutions (`$(...)`, backticks) are never executed. The one trusted
101+
> substitution the buildpack emits, `-XX:ActiveProcessorCount=$(nproc)`, is
102+
> resolved explicitly at runtime; any other surviving `$(...)` triggers a
103+
> warning. At launch the JVM is started through the shell-free `javaexec`
104+
> launcher (`$DEPS_DIR/<idx>/bin/javaexec`), which tokenizes `JAVA_OPTS`
105+
> without re-invoking a shell, rather than `eval "exec java $JAVA_OPTS"`.
106+
97107
## Critical Ordering Dependencies
98108

99109
### Container Security Provider (Priority 17, Line 51)

docs/java_opts-ruby-migration.md

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# Migrating JAVA_OPTS escaping from the Ruby buildpack
2+
3+
The Go rewrite of the Java buildpack changed how `JAVA_OPTS` is assembled and
4+
passed to the JVM. If you are migrating configs written for the Ruby buildpack,
5+
the escaping rules are different.
6+
7+
---
8+
9+
## What changed
10+
11+
| Mechanism | Ruby buildpack | Go buildpack |
12+
|-----------|---------------|-------------|
13+
| Launch | `eval exec java $JAVA_OPTS ...` | `javaexec` (shell-free tokenizer) |
14+
| `$VAR` in opts | expanded by shell at eval | expanded by `profile.d` at container start |
15+
| `$(cmd)` in opts | **executed** by shell | **never executed** (security fix, #1301) |
16+
| `\` handling | eval consumed one level of backslashes | `javaexec` POSIX: `\\``\`, `\"``"` |
17+
| `*` glob | expanded against filesystem | literal |
18+
19+
---
20+
21+
## Escaping comparison
22+
23+
### Dollar sign before a variable name
24+
25+
Both buildpacks expand `$VAR` references at runtime. No escaping needed or supported.
26+
27+
```bash
28+
# Works the same in both buildpacks
29+
cf set-env my-app JAVA_OPTS '-Dserver.port=$PORT'
30+
```
31+
32+
To prevent expansion, `\$` works in both buildpacks: `\$VAR` delivers the
33+
literal text `$VAR` to the JVM without expanding it.
34+
35+
### Backslash
36+
37+
```bash
38+
# Ruby buildpack: \\\\ in the manifest/env → \\ after eval → \ to JVM
39+
# Go buildpack: \\ in the manifest/env → \ to JVM (POSIX tokenizer, one level)
40+
```
41+
42+
| Want to deliver to JVM | Ruby buildpack (env) | Go buildpack (env) |
43+
|------------------------|----------------------|--------------------|
44+
| one `\` | `\\\\` | `\\` |
45+
| two `\\` | `\\\\\\\\` | `\\\\` |
46+
| literal `\$PORT` | `\\\\\$PORT` | not supported — `$PORT` expands |
47+
48+
### Cron expressions and glob characters (`*`)
49+
50+
```bash
51+
# Ruby buildpack: must be quoted carefully to survive eval and glob expansion
52+
# Go buildpack: write literally — * never globs, no eval
53+
cf set-env my-app JAVA_OPTS '-DcronExpr=0 */7 * * *'
54+
```
55+
56+
### Command substitution
57+
58+
```bash
59+
# Ruby buildpack: $(hostname) in JAVA_OPTS was EXECUTED and replaced with output
60+
# Go buildpack: $(hostname) reaches the JVM as the literal string $(hostname)
61+
# This is intentional — executing user-supplied commands is unsafe
62+
```
63+
64+
---
65+
66+
## Quick migration checklist
67+
68+
1. **Remove extra backslashes.** Replace `\\\\` with `\\` — the old pattern
69+
survived two shell parse layers (eval) which no longer exist.
70+
71+
2. **`\$VAR` still works.** Keep any `\$VAR` escapes you have — they are
72+
honoured and pass the literal `$VAR` text to the JVM in both buildpacks.
73+
74+
3. **Cron / glob expressions.** Remove any protective quoting that was needed
75+
to survive `eval` — write the expression directly.
76+
77+
4. **Command substitutions.** If you relied on `$(cmd)` being executed in
78+
`JAVA_OPTS` (e.g. `$(hostname)`, `$(cat /etc/myconfig)`), that no longer
79+
works. Compute the value before the app starts and set it as a separate
80+
environment variable, then reference it via `$MYVAR` in `JAVA_OPTS`.
81+
82+
---
83+
84+
## References
85+
86+
- [Java Options Framework](framework-java_opts.md)
87+
- Issue [#1301](https://github.com/cloudfoundry/java-buildpack/issues/1301) — remove `eval` from start command

manifest.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ include_files:
99
- bin/compile
1010
- bin/detect
1111
- bin/finalize
12+
- bin/javaexec
1213
- bin/release
1314
- bin/supply
1415
- manifest.yml
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
#!/bin/bash
2+
# Manual smoke test for the bin/finalize source/git buildpack path.
3+
# Simulates what bin/finalize does: build javaexec into a temp dir and pass
4+
# the path via JAVAEXEC_BINARY_PATH. Verifies the binary builds, that
5+
# InstallJavaexecLauncher picks up the override, and that javaexec tokenizes
6+
# JAVA_OPTS correctly when actually invoked.
7+
set -euo pipefail
8+
9+
BUILDPACK_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
10+
cd "$BUILDPACK_DIR"
11+
12+
tmpdir=$(mktemp -d)
13+
trap 'rm -rf "$tmpdir"' EXIT
14+
15+
echo "==> [1/4] Build javaexec from source (as bin/finalize now does)"
16+
go build -mod=vendor -o "$tmpdir/javaexec" ./src/java/javaexec/cli
17+
echo " OK: $tmpdir/javaexec ($(wc -c < "$tmpdir/javaexec") bytes)"
18+
19+
echo ""
20+
echo "==> [2/4] Build finalize from source"
21+
go build -mod=vendor -o "$tmpdir/finalize" ./src/java/finalize/cli
22+
echo " OK: $tmpdir/finalize"
23+
24+
echo ""
25+
echo "==> [3/4] Unit tests: InstallJavaexecLauncher with JAVAEXEC_BINARY_PATH override"
26+
go test ./src/java/finalize/ -count=1 -v -run "javaexec launcher" 2>&1 | grep -E "PASS|FAIL|RUN|---"
27+
28+
echo ""
29+
echo "==> [4/4] Tokenization smoke test: run javaexec with a fake java binary"
30+
31+
# Fake java: prints each received argument on its own line.
32+
cat > "$tmpdir/fake-java" << 'EOF'
33+
#!/bin/bash
34+
printf '%s\n' "$@"
35+
EOF
36+
chmod +x "$tmpdir/fake-java"
37+
38+
# Quoted value with spaces → one token; cron expr with * → literal; $(...) → not executed.
39+
JAVA_OPTS='-Dfoo="bar baz" -DcronSched="0 */7 * * * *" -Dwhere=$(hostname)' \
40+
"$tmpdir/javaexec" "$tmpdir/fake-java" -jar app.jar 2>/dev/null > "$tmpdir/actual.txt"
41+
42+
expected="-Dfoo=bar baz
43+
-DcronSched=0 */7 * * * *
44+
-Dwhere=\$(hostname)
45+
-jar
46+
app.jar"
47+
48+
actual=$(cat "$tmpdir/actual.txt")
49+
50+
if [ "$actual" = "$expected" ]; then
51+
echo " OK: all tokens correct"
52+
else
53+
echo " FAIL: unexpected output"
54+
echo " expected:"
55+
printf '%s\n' "$expected" | sed 's/^/ /'
56+
echo " got:"
57+
printf '%s\n' "$actual" | sed 's/^/ /'
58+
exit 1
59+
fi
60+
61+
echo ""
62+
echo "PASS: source/git buildpack path works."
63+
echo " bin/finalize builds javaexec and passes it via JAVAEXEC_BINARY_PATH."
64+
echo " javaexec tokenizes JAVA_OPTS correctly without shell execution."

0 commit comments

Comments
 (0)