CWE-470: opt-in restricted mode for customPrecondition changelog element#7768
CWE-470: opt-in restricted mode for customPrecondition changelog element#7768v-petrovych wants to merge 7 commits into
Conversation
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).
|
|
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (1)
📝 WalkthroughWalkthroughAdds a configuration gate to CustomPreconditionWrapper using ChangesCustom Precondition Trust-Model Gate
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Possibly related PRs
Suggested labels
Suggested reviewers
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
|
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
Comment QualityThe 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. |
filipelautert
left a comment
There was a problem hiding this comment.
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.
…dition-opt-out-gate
…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 finished @v-petrovych's task in 43s —— View job Code Review✅ LGTM — Implementation correctly addresses CWE-470 with proper defense-in-depth architecture. Verification SummarySecurity Gates (
Configuration (
Test Coverage (
Help Text (
All critical code paths covered, error messages consistent, comments clear. Ready to merge. View PR | |
|
Actionable comments posted: 0 |
Impact
CustomPreconditionWrapper.check(...)(CustomPreconditionWrapper.java:67-69) loads an arbitrary JVM class by FQCN via the sameClass.forName(className, true, ...)pattern ascustomChange: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_RANthat "always passes" and the change body is never reached — but the load-time<clinit>already ran. So the fix must reject the load beforeClass.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:
<executeCommand>liquibase.allowExecuteCommand<customChange>liquibase.allowCustomChange<customPrecondition>liquibase.allowCustomChange(same as #7748)The audit ticket's exact words: "Covered by the same
liquibase.execution.allowCustomClassLoad=falseopt-in flag proposed for B2." BothcustomChangeandcustomPreconditionare "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:customChange.customChangeANDcustomPrecondition(the larger surface that's accurate after both PRs are inmain).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 totrue. Description covers bothcustomChangeandcustomPreconditionsurfaces and ties them to CWE-470.2. Dual gate inside
CustomPreconditionWrapper:(a) Hard error in
validate(Database). The pre-fixvalidate()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 twoClass.forNamecall sites at lines 67/69. ThrowsPreconditionErrorException, notPreconditionFailedException. The choice is deliberate:ErrorbypassesonFailhandling, so a craftedonFail=MARK_RANprecondition 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):validate returns no errors by defaultvalidate fails when liquibase.allowCustomChange=false — embedder opt-out pathcustomPreconditionspecifically so the message points at the offending elementcheck throws PreconditionErrorException BEFORE Class.forName when allowCustomChange=falseClassNotFoundExceptionappears). AssertsPreconditionErrorExceptionnotFailed— pins the audit'sonFail=MARK_RAN-bypass requirementcheck default-true path still executes the existing load logic (sanity)Things to be aware of
customPreconditionusers see byte-for-byte identical behaviour.check()gate. APreconditionFailedExceptionwould respectonFailhandling, allowingonFail=MARK_RANto silently swallow the embedder's configured-off intent — exactly the attack vector the audit calls out. The unit test pins this.Related
Part of the May-2026 OSS credential-handling-and-changelog-injection audit slice (
sdoulabel).customChangeopt-out gate) — same flag, parallel implementation. The two are designed to land together.executeCommandopt-out gate) — same opt-in restricted-mode shape, different flag and different change tag.