Thanks to visit codestin.com
Credit goes to github.com

Skip to content

CWE-470: opt-in restricted mode for customPrecondition changelog element#7768

Open
v-petrovych wants to merge 7 commits into
mainfrom
cwe-470-custom-precondition-opt-out-gate
Open

CWE-470: opt-in restricted mode for customPrecondition changelog element#7768
v-petrovych wants to merge 7 commits into
mainfrom
cwe-470-custom-precondition-opt-out-gate

Conversation

@v-petrovych
Copy link
Copy Markdown
Contributor

Impact

CustomPreconditionWrapper.check(...) (CustomPreconditionWrapper.java:67-69) loads an arbitrary JVM class by FQCN via the same Class.forName(className, true, ...) pattern as customChange:

customPrecondition = (CustomPrecondition) Class.forName(className, true,
        Scope.getCurrentScope().getClassLoader()).getConstructor().newInstance();
//                                              ^ static <clinit> fires here, before the cast

Same Unsafe-Reflection attack surface as B2 (DAT-23016 / PR #7748) — CWE-470.

An additional attack vector unique to this PR, called out explicitly in the audit ticket: preconditions are evaluated before the change body executes. An attacker can craft a precondition with onFail=MARK_RAN that "always passes" and the change body is never reached — but the load-time <clinit> already ran. So the fix must reject the load before Class.forName, not rely on the precondition's pass/fail logic.

Cross-PR coordination (please read)

This PR is the third sibling in a B1/B2/B3 pattern:

PR Element Flag
#7747 <executeCommand> liquibase.allowExecuteCommand
#7748 <customChange> liquibase.allowCustomChange
THIS <customPrecondition> liquibase.allowCustomChange (same as #7748)

The audit ticket's exact words: "Covered by the same liquibase.execution.allowCustomClassLoad=false opt-in flag proposed for B2." Both customChange and customPrecondition are "load arbitrary FQCN" surfaces with identical risk — one flag covers both.

Expected merge conflict with #7748

Both this PR and #7748 declare GlobalConfiguration.ALLOW_CUSTOM_CHANGE. The declarations are byte-identical for the constant; the description text differs:

Recommended merge resolution: when merging the second of the two PRs, keep this PR's broader description text and discard #7748's narrower one. The constant declaration itself dedupes mechanically (1-line edit). I considered stacking this PR on #7748's branch to avoid the conflict but chose independent PRs to keep each one reviewable in any merge order — the conflict is mechanical and 1-line.

Fix — three pieces

1. GlobalConfiguration.ALLOW_CUSTOM_CHANGE — Boolean, defaults to true. Description covers both customChange and customPrecondition surfaces and ties them to CWE-470.

2. Dual gate inside CustomPreconditionWrapper:

(a) Hard error in validate(Database). The pre-fix validate() was a trivial no-op (return new ValidationErrors();). This PR replaces that body with a configured-off check, so the operator sees a pre-execution failure during the standard validation pass.

(b) Defense-in-depth gate at the top of check(...) — fires BEFORE either of the two Class.forName call sites at lines 67/69. Throws PreconditionErrorException, not PreconditionFailedException. The choice is deliberate: Error bypasses onFail handling, so a crafted onFail=MARK_RAN precondition cannot silently swallow the embedder's configured-off intent — which is the specific attack vector the audit calls out.

3. Four new Spock specs in CustomPreconditionWrapperTest (2 pre-existing → 6 total):

Spec What it pins
validate returns no errors by default Default-true path preserves existing behaviour
validate fails when liquibase.allowCustomChange=false — embedder opt-out path Hard error, names the flag in both directions AND names customPrecondition specifically so the message points at the offending element
check throws PreconditionErrorException BEFORE Class.forName when allowCustomChange=false Sets a non-existent class name to prove the gate ran first (no wrapped ClassNotFoundException appears). Asserts PreconditionErrorException not Failed — pins the audit's onFail=MARK_RAN-bypass requirement
check default-true path still executes the existing load logic (sanity) Default-true short-circuit doesn't skip downstream behaviour
[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0 -- in CustomPreconditionWrapperTest
[INFO] BUILD SUCCESS

Things to be aware of

  • Default is unchanged. Existing customPrecondition users see byte-for-byte identical behaviour.
  • Both gates fire before any static initializer. The defense-in-depth design specifically prevents the load-time side effect — not just the cast or constructor invocation.
  • PreconditionErrorException (not Failed) is the intentional choice at the check() gate. A PreconditionFailedException would respect onFail handling, allowing onFail=MARK_RAN to silently swallow the embedder's configured-off intent — exactly the attack vector the audit calls out. The unit test pins this.
  • Chain 3 status: with this PR (B3) landing alongside CWE-78: opt-in restricted mode for executeCommand changelog change #7747 (B1) and CWE-470: opt-in restricted mode for customChange changelog change #7748 (B2), all three RCE primitives flagged in DAT-23027 (the env-var command-injection chain) now have embedder opt-out gates. B10 (env-var substitution behaviour on changeset properties) is a separate child ticket; closing the chain fully requires B10 to land too.

Related

Part of the May-2026 OSS credential-handling-and-changelog-injection audit slice (sdou label).

v-petrovych and others added 5 commits May 21, 2026 14:20
CustomPreconditionWrapper.check() loads an arbitrary JVM class by FQCN
via Class.forName(className, true, classLoader) — the same Unsafe
Reflection pattern as customChange (B2 / DAT-23016 / PR #7748). The
initialize=true argument means the named class's static <clinit>
initializer fires AT LOAD TIME, before the cast to CustomPrecondition
or any other safety check could reject the load. Any class on the JVM
classpath is reachable from a malicious changelog this way: name it
in a <customPrecondition>, get its static init + zero-arg ctor run
as a side effect (CWE-470).

The audit ticket flags an attack vector specific to this PR:
preconditions are evaluated BEFORE the change body, providing a
separate class-load trigger point that fires even if the change is
never reached. An attacker can craft an onFail=MARK_RAN precondition
that 'always passes' — but the load-time static initializer already
ran. This PR's gate must therefore reject the load BEFORE
Class.forName, NOT rely on the precondition's pass/fail logic.

Three pieces:

1. New GlobalConfiguration.ALLOW_CUSTOM_CHANGE flag
   (liquibase.allowCustomChange). Boolean, defaults to true. This is
   the SAME flag introduced by sibling PR #7748 (DAT-23016, B2 /
   customChange) — per the audit author's explicit guidance in this
   ticket: 'Covered by the same flag proposed for B2.' The flag
   description in this PR covers BOTH customChange and
   customPrecondition. Note for the maintainer: GlobalConfiguration
   .java will have a textual conflict with #7748 at merge time
   (both PRs declare the same constant). The conflict is mechanical
   (dedupe one declaration); prefer this PR's broader description
   text since it covers the larger surface.

2. Dual gate inside CustomPreconditionWrapper:

   (a) Hard error in validate(Database). The pre-fix validate() was
       a trivial no-op (returns empty ValidationErrors); the gate
       replaces that body with a configured-off check. Operator
       sees a clean pre-execution failure during the standard
       validation pass.

   (b) Defense-in-depth gate at the top of check(...) — fires BEFORE
       either of the two Class.forName call sites at lines 67/69.
       Throws PreconditionErrorException (NOT
       PreconditionFailedException) deliberately: Error bypasses
       onFail handling so a crafted onFail=MARK_RAN cannot silently
       swallow the embedder's configured-off intent, which is the
       specific attack vector flagged in the audit.

3. Four new Spock specs in CustomPreconditionWrapperTest:

   - validate returns no errors by default (default-true path
     preserves existing behaviour).
   - validate fails with the configured-off hard error when
     allowCustomChange=false, naming the flag in both directions
     AND naming 'customPrecondition' specifically so operators
     know which element triggered the message.
   - check throws PreconditionErrorException BEFORE Class.forName
     when allowCustomChange=false. Uses a non-existent class name
     to prove the gate ran first: pre-fix code path would have
     produced a wrapped ClassNotFoundException; after-fix produces
     ONLY the configured-off message — the loader never ran. Also
     asserts PreconditionErrorException (not Failed) — pinning the
     audit's onFail=MARK_RAN-bypass requirement.
   - check default-true path still executes existing load logic
     (sanity that the gate's short-circuit doesn't skip downstream
     behaviour at the default).

6/6 CustomPreconditionWrapperTest specs pass (2 pre-existing + 4 new).

Chain 3 status: with this PR closing B3 alongside #7747 (B1) and
#7748 (B2), all three RCE primitives flagged in DAT-23027 (the
broader env-var command-injection chain) now have embedder opt-out
gates. B10 (env-var substitution behaviour on changeset properties)
is a separate child ticket; closing the chain fully requires B10
to land too.
…derabbitai)

CodeRabbit's nit on #7749: the two default-true specs read the
ambient ALLOW_CUSTOM_CHANGE value rather than explicitly scoping it.
If another spec, the runner, or a stale system property leaves
liquibase.allowCustomChange=false in scope, these two specs would
fail spuriously even though the code under test is correct.

The flag-=false specs already use Scope.child([...flag: "false"], ...);
the symmetric fix is to wrap the default-true specs in
Scope.child([...flag: "true"], ...) so each spec is hermetic w.r.t.
the ambient flag state.

Two specs updated:

1. 'validate returns no errors by default — customPrecondition is
   intentional under the standard trust model (CWE-470 opt-out gate)'
   — wrap precondition.validate(...) in Scope.child.

2. 'check default-true path still executes the existing load logic
   (sanity)' — wrap precondition.check(...) in Scope.child.

Comment in each spec credits @coderabbitai and explains WHY the
explicit-scope form is required (not just default behaviour).

6/6 CustomPreconditionWrapperTest specs still pass.
Resolve GlobalConfiguration.java conflict with #7747's ALLOW_EXECUTE_COMMAND
(now in main). Both declarations and both builder.define blocks are kept,
alphabetical order (CHANGE before COMMAND). This branch keeps the broader
ALLOW_CUSTOM_CHANGE description that covers both customChange and
customPrecondition — per Filipe's coordination note on this PR, that
description is the correct end-state. 6/6 CustomPreconditionWrapperTest
specs pass.

Note: a second textual conflict with #7748 (both declare ALLOW_CUSTOM_CHANGE
at the same spot in GlobalConfiguration.java) will materialize once #7748
merges first; that's a separate resolution pass.
Resolve GlobalConfiguration.java conflict with #7748's ALLOW_CUSTOM_CHANGE
declaration (now in main). The declaration line itself is byte-identical in
both branches and git auto-merged it cleanly; the only conflict was the
builder.setDescription() text — #7748 shipped the customChange-only narrow
description, this PR carries the broader description that covers both
customChange AND customPrecondition. Per the maintainer's coordination
note on this PR's review, the broader description is the correct end-state
once both gates are in place. The narrower text is replaced; the rest of
#7748 (the CustomChangeWrapper dual gate and its 4 new test specs) is
pulled in unchanged.

45/45 specs pass across CustomPreconditionWrapperTest (6, this PR's),
CustomChangeWrapperTest (28, from #7748), and GlobalConfigurationTest (11).
@CLAassistant
Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 26, 2026

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 38663e42-6b58-4c2c-b5d3-3bb962b32450

📥 Commits

Reviewing files that changed from the base of the PR and between e0b8da3 and f35f1e3.

📒 Files selected for processing (1)
  • liquibase-cli/src/test/groovy/liquibase/integration/commandline/LiquibaseCommandLineTest.groovy

📝 Walkthrough

Walkthrough

Adds a configuration gate to CustomPreconditionWrapper using liquibase.allowCustomChange, updates the configuration/help text to mention customPrecondition, and adds tests and CLI fixture updates to validate gating behavior in both validation and execution paths.

Changes

Custom Precondition Trust-Model Gate

Layer / File(s) Summary
Configuration description update
liquibase-standard/src/main/java/liquibase/GlobalConfiguration.java
The ALLOW_CUSTOM_CHANGE configuration description is updated to explicitly mention both customPrecondition and customChange elements, clarifying that both trigger Class.forName(initialize=true) with static initializer execution.
Precondition wrapper gate implementation
liquibase-standard/src/main/java/liquibase/precondition/CustomPreconditionWrapper.java
The GlobalConfiguration import is added, and validate() and check() methods enforce the configuration gate: validate() returns errors when disabled, and check() throws PreconditionErrorException before any reflective class loading.
Gate behavior validation tests
liquibase-standard/src/test/groovy/liquibase/precondition/CustomPreconditionWrapperTest.groovy
New Spock specs verify that validate() produces no errors when the flag is enabled and detailed errors when disabled; check() tests confirm PreconditionErrorException is thrown before class loading when the flag is disabled, plus a sanity check for normal execution when enabled.
CLI help fixture update
liquibase-cli/src/test/groovy/liquibase/integration/commandline/LiquibaseCommandLineTest.groovy
Updated expected help text to reflect expanded wording for --allow-custom-change, covering both customChange and customPrecondition and the Class.forName class-loading description.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • liquibase/liquibase#7760: The main PR’s updated liquibase.allowCustomChange help/description text, which affects the CLI help-text fixture.
  • liquibase/liquibase#7748: Applies the same liquibase.allowCustomChange gate pattern to CustomChangeWrapper, aligning with this PR’s extension to custom preconditions.

Suggested labels

TypeEnhancement

Suggested reviewers

  • wwillard7800
  • filipelautert
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: adding an opt-in restricted mode for the customPrecondition element to address CWE-470.
Description check ✅ Passed The PR description is comprehensive and includes detailed impact, risk explanation, cross-PR coordination, implementation details, testing results, and relevant context, though it deviates slightly from the template structure.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch cwe-470-custom-precondition-opt-out-gate

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented May 26, 2026

Claude finished @v-petrovych's task in 41s —— View job


Code Review

✅ LGTM — This PR correctly implements the CWE-470 security gate for customPrecondition with proper defense-in-depth architecture and comprehensive test coverage.

Key Strengths

  • Dual security gates: Validation gate provides operator feedback; runtime gate in check() executes before Class.forName to prevent load-time attacks — exactly matching the audit requirement.
  • Exception type choice is correct: Using PreconditionErrorException (not PreconditionFailedException) prevents onFail=MARK_RAN from bypassing the configured-off intent — this addresses the specific attack vector flagged in the audit.
  • Configuration reuse: Correctly leverages GlobalConfiguration.ALLOW_CUSTOM_CHANGE (same flag as CWE-470: opt-in restricted mode for customChange changelog change #7748's customChange) with updated description covering both elements.
  • Comprehensive test coverage: All critical paths tested — default-true preserves existing behavior, false path in both validate() and check(), and proof that gate runs BEFORE Class.forName (by using non-existent class name).
  • Test hermiticity: Tests explicitly scope the flag to true/false rather than relying on ambient defaults, preventing test pollution from other tests or system properties.

Comment Quality

The implementation includes clear documentation of the CWE-470 vulnerability, explains why defense-in-depth is necessary (preconditions eval before changes execute), and justifies the exception type choice. Both validation and check gates use identical error messages for consistency.


Copy link
Copy Markdown
Collaborator

@filipelautert filipelautert left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-approving the org-repo replacement for #7749 (closed-without-merge fork PR — environment: external gate workaround you described in Slack). Diff is byte-identical to the version I approved on Monday (verified). Original architectural review still applies: shared ALLOW_CUSTOM_CHANGE flag with customChange, PreconditionErrorException (not Failed) correctly bypasses onFail=MARK_RAN, hermetic test scoping with explicit Scope.child([... : "true"], ...), non-existent-classname spec proves the gate fires before Class.forName. Ship it.

…dHelpOutput for the updated allow-custom-change description

This PR extends the existing ALLOW_CUSTOM_CHANGE flag to also gate
customPrecondition (in addition to customChange) and updates its description
in GlobalConfiguration to reflect the broader scope. The help-output snapshot
reflects over registered config definitions, so the rendered --help block for
--allow-custom-change now carries the new wording and the fixture must match.

Unlike the sibling CWE PRs (#7765/#7766/#7767) which insert a new flag block,
this PR replaces the existing allow-custom-change block (22 lines vs 21
previously) — captured byte-exact from picocli's actual --help output.

LiquibaseCommandLineTest 40/40 green locally.
@claude
Copy link
Copy Markdown
Contributor

claude Bot commented May 28, 2026

Claude finished @v-petrovych's task in 43s —— View job


Code Review

LGTM — Implementation correctly addresses CWE-470 with proper defense-in-depth architecture.

Verification Summary

Security Gates (CustomPreconditionWrapper.java):

  • validate() (lines 57-74): Rejects disabled flag at pre-execution phase, returns clear errors naming both flag states and customPrecondition specifically
  • check() (lines 77-97): Defense-in-depth gate fires before Class.forName at lines 103-105, preventing load-time static initializer execution even if change body unreachable
  • Exception type choice (PreconditionErrorException not PreconditionFailedException) is correct — bypasses onFail=MARK_RAN to prevent silent swallowing of configured-off intent (audit's specific attack vector)

Configuration (GlobalConfiguration.java:230-242):

Test Coverage (CustomPreconditionWrapperTest.groovy):

  • Default-true path: validates no errors and normal execution
  • Disabled path: both validate() and check() reject with proper exceptions
  • Defense-in-depth proof: non-existent class name confirms gate fires before Class.forName
  • Explicit Scope.child() scoping with flag set to "true"/"false" prevents test pollution

Help Text (LiquibaseCommandLineTest.groovy):

  • Updated --allow-custom-change description consistent with GlobalConfiguration wording

All critical code paths covered, error messages consistent, comments clear. Ready to merge.

View PR |

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 28, 2026

Actionable comments posted: 0

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants