Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
e738669
fix(java_opts): preserve quoting and prevent JAVA_OPTS glob expansion…
stokpop May 28, 2026
91fbd55
fix(java_opts): use printf for USER_JAVA_OPTS normalization
stokpop Jun 5, 2026
5503020
fix(java_opts): use printf in eval expansion path
stokpop Jun 5, 2026
083de4e
fix(java_opts): escape replacement chars in placeholder substitution
stokpop Jun 5, 2026
7183dc8
test/docs: clarify JavaExecCommand comment and align Release test pla…
stokpop Jun 5, 2026
9741147
refactor(java_opts): hoist invariant escaping and add double-backslas…
stokpop Jun 5, 2026
edbf42d
fix(play): quote eval java command for JAVA_OPTS safety (#1301)
stokpop Jun 5, 2026
0d38e92
Replace eval with pure-bash expander for .opts variable expansion
stokpop Jun 6, 2026
87c3e45
Replace start-command eval with a shell-free javaexec launcher
stokpop Jun 6, 2026
ab89e41
Merge branch 'main' into issue-1301-java-opts-quoting
stokpop Jun 8, 2026
afa8f9d
fix: escape double quotes in JBP_CONFIG_JAVA_MAIN arguments; rename u…
stokpop Jun 8, 2026
b277fab
fix(java_opts): escape " and \ in opts_content before eval expansion
stokpop Jun 8, 2026
abf8ccc
Merge branch 'issue-1301-java-opts-quoting' into issue-1301-replace-e…
stokpop Jun 9, 2026
1d97a85
fix(tests): correct SapMachine log assertion strings
stokpop Jun 10, 2026
96ae533
fix(tests): correct Tomcat version assertion strings
stokpop Jun 10, 2026
2bb06e1
feat(java_opts): safe runtime expansion and \$VAR escape (#1301)
stokpop Jun 10, 2026
078340d
docs(java_opts): document runtime variable expansion and Ruby migration
stokpop Jun 10, 2026
955a376
test(integration): add javaexec regression tests for #1301
stokpop Jun 10, 2026
25c599b
test(java_opts): add end-to-end javaexec tests for issue #1301 scenarios
stokpop Jun 10, 2026
7d87e31
fix(jres): use \$DEPS_DIR instead of hardcoded /home/vcap/deps
stokpop Jun 10, 2026
e787b0a
fix(javaexec): keep \$(...), \${...}, and backtick subs as one token
stokpop Jun 10, 2026
3243574
test(java_opts): document POSIX word-split behavior for runtime env v…
stokpop Jun 10, 2026
c44a2c7
fix(java_opts): warning shows only matching $(...) token, not full JA…
stokpop Jun 10, 2026
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ tmp/
.cache/
bin/detect
bin/finalize
bin/javaexec
bin/release
bin/supply
/*.md
Expand Down
84 changes: 65 additions & 19 deletions docs/framework-java_opts.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,37 +26,83 @@ The framework can be configured by creating or modifying the [`config/java_opts.

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.

## Escaping strings
## Runtime variable expansion

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.
Java options are assembled at container start by the buildpack's `profile.d` script
(`00_java_opts.sh`), then passed to the JVM by the shell-free `javaexec` launcher.
Because `javaexec` tokenizes `JAVA_OPTS` without invoking a shell, characters such as
`*`, `&`, `;`, `|`, and `>` are treated as literals — they reach the JVM exactly as
written.

```bash
cf set-env my-application JAVA_OPTS '-Dexample.port=$PORT'
```

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.
### Environment variable references

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

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

From the [`config/java_opts.yml`][] file use;
```yaml
from_environment: true
java_opts: '-Dexample.other=something.\\$dollar.\\\\slash'
# config/java_opts.yml
java_opts: '-Xloggc:$PWD/beacon_gc.log -verbose:gc'
```

Finally, from the applications manifest use;
```yaml
env:
JAVA_OPTS: '-Dexample.other=something.\\\\\$dollar.\\\\\\\slash'
### Command substitutions are never executed

`$(...)` and backtick command substitutions are **not** executed. A value such as
`-Dinject=$(hostname)` reaches the JVM as the literal string `-Dinject=$(hostname)`.
This is intentional: executing arbitrary commands from a user-supplied option string
would be a security vulnerability.

### Processor count: `$(nproc)`

The one exception is `-XX:ActiveProcessorCount=$(nproc)`, which the buildpack itself
emits for JRE vendors that need it. The profile.d script resolves this single known
token to the actual CPU count before passing the option to the JVM. Any other
`$(...)` expression passes to the JVM literally.

### Special characters and quoting

Characters that were shell-special under the old `eval`-based launcher (`*`, `&`,
`;`, `|`, `>`) are now passed to the JVM as literals — no quoting tricks required.

POSIX quoting in the assembled `JAVA_OPTS` string is respected by `javaexec`'s
tokenizer: a quoted value such as `"-Dfoo=bar baz"` is delivered as the single
argument `-Dfoo=bar baz`.

| Want to pass to JVM | Write in `JAVA_OPTS` / `java_opts` |
|---------------------|-------------------------------------|
| Literal `$PORT` (no expansion) | `\$PORT` |
| Literal `\` backslash | `\\` |
| Literal `\\` two backslashes | `\\\\` |
| Value of `$PORT` at runtime | `$PORT` |
| Cron expression `0 */7 * * *` | `0 */7 * * *` (no quoting needed) |
| Space inside one JVM arg | `"-Dfoo=bar baz"` (quote the arg) |

```bash
# Expand $PORT at runtime
cf set-env my-application JAVA_OPTS '-Dserver.port=$PORT'

# Literal $PORT — not expanded
cf set-env my-application JAVA_OPTS '-Dexample.literal=\$PORT'

# Windows-style path — \\ becomes one backslash
cf set-env my-application JAVA_OPTS '-Dapp.data=C:\\data\\app'

# Cron expression — * is not glob-expanded
cf set-env my-application JAVA_OPTS '-DcronExpr=0 */7 * * *'
```

> **Note:** `$` followed by a digit or non-identifier character (e.g. `$1`, `$.`)
> is left as-is. Undefined variables expand to an empty string.

> **Migrating from the Ruby buildpack?** See
> [Migrating JAVA_OPTS escaping from the Ruby buildpack](java_opts-ruby-migration.md)
> for a comparison of the escaping rules.

## Examples

### Configuration File Example
Expand Down
10 changes: 10 additions & 0 deletions docs/framework-ordering.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,16 @@ This ensures:
2. **Container Security Provider runs BEFORE JRebel** (07 < 20)
3. **User JAVA_OPTS override everything** (99 runs last)

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

## Critical Ordering Dependencies

### Container Security Provider (Priority 17, Line 51)
Expand Down
87 changes: 87 additions & 0 deletions docs/java_opts-ruby-migration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# Migrating JAVA_OPTS escaping from the Ruby buildpack

The Go rewrite of the Java buildpack changed how `JAVA_OPTS` is assembled and
passed to the JVM. If you are migrating configs written for the Ruby buildpack,
the escaping rules are different.

---

## What changed

| Mechanism | Ruby buildpack | Go buildpack |
|-----------|---------------|-------------|
| Launch | `eval exec java $JAVA_OPTS ...` | `javaexec` (shell-free tokenizer) |
| `$VAR` in opts | expanded by shell at eval | expanded by `profile.d` at container start |
| `$(cmd)` in opts | **executed** by shell | **never executed** (security fix, #1301) |
| `\` handling | eval consumed one level of backslashes | `javaexec` POSIX: `\\`→`\`, `\"` → `"` |
| `*` glob | expanded against filesystem | literal |

---

## Escaping comparison

### Dollar sign before a variable name

Both buildpacks expand `$VAR` references at runtime. No escaping needed or supported.

```bash
# Works the same in both buildpacks
cf set-env my-app JAVA_OPTS '-Dserver.port=$PORT'
```

To prevent expansion, `\$` works in both buildpacks: `\$VAR` delivers the
literal text `$VAR` to the JVM without expanding it.

### Backslash

```bash
# Ruby buildpack: \\\\ in the manifest/env → \\ after eval → \ to JVM
# Go buildpack: \\ in the manifest/env → \ to JVM (POSIX tokenizer, one level)
```

| Want to deliver to JVM | Ruby buildpack (env) | Go buildpack (env) |
|------------------------|----------------------|--------------------|
| one `\` | `\\\\` | `\\` |
| two `\\` | `\\\\\\\\` | `\\\\` |
| literal `\$PORT` | `\\\\\$PORT` | not supported — `$PORT` expands |

### Cron expressions and glob characters (`*`)

```bash
# Ruby buildpack: must be quoted carefully to survive eval and glob expansion
# Go buildpack: write literally — * never globs, no eval
cf set-env my-app JAVA_OPTS '-DcronExpr=0 */7 * * *'
```

### Command substitution

```bash
# Ruby buildpack: $(hostname) in JAVA_OPTS was EXECUTED and replaced with output
# Go buildpack: $(hostname) reaches the JVM as the literal string $(hostname)
# This is intentional — executing user-supplied commands is unsafe
```

---

## Quick migration checklist

1. **Remove extra backslashes.** Replace `\\\\` with `\\` — the old pattern
survived two shell parse layers (eval) which no longer exist.

2. **`\$VAR` still works.** Keep any `\$VAR` escapes you have — they are
honoured and pass the literal `$VAR` text to the JVM in both buildpacks.

3. **Cron / glob expressions.** Remove any protective quoting that was needed
to survive `eval` — write the expression directly.

4. **Command substitutions.** If you relied on `$(cmd)` being executed in
`JAVA_OPTS` (e.g. `$(hostname)`, `$(cat /etc/myconfig)`), that no longer
works. Compute the value before the app starts and set it as a separate
environment variable, then reference it via `$MYVAR` in `JAVA_OPTS`.

---

## References

- [Java Options Framework](framework-java_opts.md)
- Issue [#1301](https://github.com/cloudfoundry/java-buildpack/issues/1301) — remove `eval` from start command
1 change: 1 addition & 0 deletions manifest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ include_files:
- bin/compile
- bin/detect
- bin/finalize
- bin/javaexec
- bin/release
- bin/supply
- manifest.yml
Expand Down
27 changes: 27 additions & 0 deletions src/integration/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,33 @@ The integration tests cover:
- Cached buildpack deployment
- No internet access scenarios

### Note: JAVA_OPTS character-fidelity is tested at the unit level, not here (#1301)

The `#1301` work removed `eval` from the start command: `JAVA_OPTS` is now assembled
by a pure-bash expander and tokenized at launch by the shell-free `javaexec` launcher.
The exact-character behaviour this guarantees — shell metacharacters (`; & | > `),
glob/cron stars, quotes-with-spaces, and backslashes all reaching the JVM as literal
text — is verified **deterministically at the unit level** in
[`../java/frameworks/java_opts_writer_test.go`](../java/frameworks/java_opts_writer_test.go),
which runs the **real** generated assembly script and the **real** `javaexec`
tokenizer end-to-end.

Do **not** re-assert those exact characters through a docker deployment here. The
switchblade docker harness passes env vars into the container in a way that mangles
some metacharacters (e.g. a user `JAVA_OPTS` `&` was observed arriving as `\&` inside
the container) — that is a **harness artifact, not buildpack behaviour** (the unit
test above confirms the buildpack delivers a clean `&`). A docker integration test
asserting the literal received value would fail for reasons unrelated to the product.

What this directory **does** cover for `#1301`, because it is a genuine end-to-end
property that must hold in a real container launch:

- **Command substitution is never executed** — a `$(...)` in user `JAVA_OPTS` reaches
the JVM as literal text (`SpringBoot` suite, asserted via the fixture's `/jvm-args`
endpoint).
- **The `javaexec` launcher actually starts the JVM** for the affected containers,
including `Play` (previously `eval exec java $JAVA_OPTS ...`).

## Writing New Tests

To add a new test:
Expand Down
2 changes: 1 addition & 1 deletion src/integration/java_main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ func testJavaMain(platform switchblade.Platform, fixtures string) func(*testing.

// Verify SAPMachine JRE was installed from manifest
Expect(logs.String()).To(ContainSubstring("Java Buildpack"))
Expect(logs.String()).To(ContainSubstring("Installing SAP Machine"))
Expect(logs.String()).To(ContainSubstring("Installing SapMachine"))
Expect(logs.String()).To(ContainSubstring("17."))
})
})
Expand Down
17 changes: 17 additions & 0 deletions src/integration/play_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,23 @@ func testPlay(platform switchblade.Platform, fixtures string) func(*testing.T, s
Eventually(deployment).Should(matchers.Serve(Not(BeEmpty())))
})

// Regression test for #1301: the Play start command now launches the JVM via
// the shell-free javaexec launcher (previously `eval exec java $JAVA_OPTS ...`).
// A command substitution and cron/glob characters in user JAVA_OPTS must not be
// executed or expanded, and must not break launch — the app must still boot.
it("starts via the javaexec launcher with unsafe JAVA_OPTS (#1301)", func() {
deployment, logs, err := platform.Deploy.
WithEnv(map[string]string{
"BP_JAVA_VERSION": "11",
"JAVA_OPTS": `-DcronExpr="0 */7 * * * *" -Dinject=$(hostname)`,
}).
Execute(name, filepath.Join(fixtures, "containers", "play_2.2_staged"))
Expect(err).NotTo(HaveOccurred(), logs.String)

Expect(logs.String()).To(ContainSubstring("Java Buildpack"))
Eventually(deployment).Should(matchers.Serve(Not(BeEmpty())))
})

it("handles Play 2.2 application without bat file", func() {
deployment, logs, err := platform.Deploy.
WithEnv(map[string]string{
Expand Down
37 changes: 37 additions & 0 deletions src/integration/spring_boot_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,43 @@ func testSpringBoot(platform switchblade.Platform, fixtures string) func(*testin
Eventually(deployment).Should(matchers.Serve(ContainSubstring("Hello from Spring Boot")))
})

// Regression tests for #1301: the start command no longer uses `eval`, and
// JAVA_OPTS is assembled by a pure-bash expander and tokenized at launch by
// the shell-free javaexec launcher. A user-supplied JAVA_OPTS value must
// therefore reach the JVM as literal text — command substitutions are never
// executed, and globs/cron stars/shell metacharacters are never expanded or
// interpreted as operators. These drive the value through the user JAVA_OPTS
// env path (from_environment: true; memory comes from the configured opts),
// then read the JVM's actual received value back via the fixture's /jvm-args
// endpoint (System.getProperty("userProperty")).
memoryOpts := `java_opts: ["-Xmx256M", "-Xms128M", "-Xss512k", "-XX:ReservedCodeCacheSize=120M", "-XX:MetaspaceSize=78643K", "-XX:MaxMetaspaceSize=157286K"]`

it("does not execute command substitution in JAVA_OPTS (#1301, no eval)", func() {
deployment, logs, err := platform.Deploy.
WithEnv(map[string]string{
"BP_JAVA_VERSION": "17",
"JBP_CONFIG_JAVA_OPTS": `{from_environment: true, ` + memoryOpts + `}`,
// $(hostname) would run under the old eval start command. It must
// instead arrive at the JVM verbatim.
"JAVA_OPTS": `-DuserProperty=$(hostname)`,
}).
Execute(name, filepath.Join(fixtures, "containers", "spring_boot_staged"))
Expect(err).NotTo(HaveOccurred(), logs.String)

Eventually(deployment).Should(matchers.Serve(
ContainSubstring("userProperty=$(hostname)"), // literal, not the executed hostname
).WithEndpoint("/jvm-args"))
})

// NOTE: glob/cron preservation and shell-metacharacter/ampersand/backslash
// fidelity are covered deterministically at the unit level in
// frameworks/java_opts_writer_test.go, which runs the real assembly script
// and the real javaexec tokenizer. They are intentionally not duplicated as
// docker integration tests (the switchblade docker harness mangles some
// metacharacters when passing env vars, which is a harness artifact, not
// buildpack behaviour). The command-substitution case above stays here
// because non-execution is the security property worth proving end-to-end.

it("applies framework opts without any configured JAVA_OPTS", func() {
deployment, logs, err := platform.Deploy.
WithEnv(map[string]string{
Expand Down
Loading