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

Skip to content

CWE-470: opt-in restricted mode for includeAll resourceFilter and resourceComparator classes#7766

Open
v-petrovych wants to merge 3 commits into
mainfrom
cwe-470-include-all-classes-opt-out-gate
Open

CWE-470: opt-in restricted mode for includeAll resourceFilter and resourceComparator classes#7766
v-petrovych wants to merge 3 commits into
mainfrom
cwe-470-include-all-classes-opt-out-gate

Conversation

@v-petrovych
Copy link
Copy Markdown
Contributor

Impact

The <includeAll> changelog directive accepts resourceFilter and resourceComparator attributes that specify fully-qualified Java class names. Both are loaded via Class.forName(name, true, ContextClassLoader).getConstructor().newInstance() with no allowlist and no marker-interface check before construction:

// DatabaseChangeLog.java:641 — resourceFilter
resourceFilter = (IncludeAllFilter) Class.forName(resourceFilterDef, true,
    Thread.currentThread().getContextClassLoader()).getConstructor().newInstance();

// DatabaseChangeLog.java:731 — resourceComparator
resourceComparator = (Comparator<String>) Class.forName(resourceComparatorDef, true,
    Thread.currentThread().getContextClassLoader()).getConstructor().newInstance();

The initialize=true argument fires the named class's static <clinit> initializer at load time — before the cast or any other safety check could reject the load. Any class on the JVM classpath is reachable via a malicious <includeAll>: name the class, get its static-init code and zero-arg constructor invoked as a side effect.

This maps to CWE-470 (Use of Externally-Controlled Input to Select Classes or Code, aka Unsafe Reflection) — the same surface as <customChange> and <customPrecondition>, applied to a different parse-time entry point.

Scope decision (sibling to #7748 and #7749) — separate flag, not shared

The audit author originally suggested unifying B2/B3/B5 under one flag (liquibase.execution.allowCustomClassLoad). After deliberation this PR uses a separate flag for two reasons:

  1. <includeAll> is not a "custom-*" element. Extending liquibase.allowCustomChange (which gates <customChange> and <customPrecondition>) to also gate <includeAll> would stretch the flag name beyond what an operator reading allowCustomChange would reasonably expect.
  2. Distinct flags give operators independent control surface. An embedder who uses <customChange> but not <includeAll> filters (or vice versa) can lock down each independently.

To prevent the "operator missed a flag" footgun that motivated #7749 sharing #7748's flag, this PR's new-flag description cross-references liquibase.allowCustomChange. Operators reading either description learn that fully locking down changelog-controlled class loading requires setting both flags to false. Open to feedback if reviewers prefer the unified-flag approach instead — the swap would be mechanical.

No default behaviour change. Every existing <includeAll> continues to work byte-for-byte identically at the default flag value.

Fix — three pieces

1. New GlobalConfiguration.ALLOW_INCLUDE_ALL_CLASSES flagliquibase.allowIncludeAllClasses, Boolean, defaults to true. Description references CWE-470 and cross-references liquibase.allowCustomChange.

2. Two inline gates in DatabaseChangeLog:

(a) In handleIncludeAll() at the resourceFilter Class.forName call site (line 641 in the audit reference). Throws SetupException with the configured-off message BEFORE the Class.forName, so the static initializer of the named class never fires when the flag is false.

(b) In determineResourceComparator() at the resourceComparator Class.forName call site (line 731 in the audit reference). Throws UnexpectedLiquibaseException (the method has no checked-throws clause). Critically, the gate fires BEFORE the try/catch block — the existing ReflectiveOperationException-catches-and-falls-back-to-standard logic would otherwise silently degrade the configured-off intent into the default comparator (which would mask the rejection from the operator and look identical to the success path in logs).

3. Five new Spock specs in DatabaseChangeLogTest (195 pre-existing → 200 total):

Spec What it pins
determineResourceComparator returns the standard comparator when allowIncludeAllClasses=true (default) and resourceComparatorDef is null Default-path preservation. Behaviour-asserts via comparator.compare(...) rather than reference-equality because getStandardChangeLogComparator() returns a fresh Comparator.comparing(...) lambda each call.
determineResourceComparator falls back to standard comparator at default flag when resourceComparator class fails to load (preserves pre-fix catch behaviour) flag=true must not regress the existing graceful-fallback path.
determineResourceComparator throws UnexpectedLiquibaseException BEFORE Class.forName when allowIncludeAllClasses=false The strongest assertion in this section: uses a non-existent class name. Pre-fix code path would have fallen back to standard (no exception); after-fix produces ONLY the configured-off error — the loader never ran. Message-shape match (flag-name in both directions + "NOT loaded" + "resourceComparator") is the proof-of-short-circuit.
determineResourceComparator with null resourceComparatorDef returns standard comparator even when allowIncludeAllClasses=false Pins that the gate does NOT over-reach into the default-comparator path when there's nothing to gate.
handleIncludeAll throws SetupException BEFORE Class.forName when allowIncludeAllClasses=false and resourceFilter is specified Parser-path test exercising the inline gate at line 641 via the standard load() entry point with a ParsedNode-based changelog.
[INFO] Tests run: 200, Failures: 0, Errors: 0, Skipped: 0 -- in DatabaseChangeLogTest
[INFO] Tests run: 11, Failures: 0, Errors: 0, Skipped: 0 -- in GlobalConfigurationTest
[INFO] BUILD SUCCESS

Broader sanity sweep: 262 tests across DatabaseChangeLogTest (200), XMLChangeLogSAXParser_RealFile_Test (30), and GlobalConfigurationTest (11) — 0 failures, 1 platform-conditional skip.

Things to be aware of

  • Default is unchanged. Existing <includeAll> users see no behaviour change.
  • Both gates fire before any static initializer runs. The defense-in-depth design specifically prevents the load-time side effect.
  • determineResourceComparator is public. External callers passing a class name will now hit the gate; null-comparator callers are unaffected. This is intentional — any code path that passes a changelog-controlled FQCN to this method should respect the flag.
  • Cross-reference convention. This PR adds the cross-reference only one-way (the new flag's description points at liquibase.allowCustomChange). The reverse cross-reference (updating liquibase.allowCustomChange to point at this new flag) is deferred to avoid stacking yet another description-text conflict on top of the CWE-470: opt-in restricted mode for customChange changelog change #7748CWE-470: opt-in restricted mode for customPrecondition changelog element #7749 merge sequence already in flight. Happy to do that cleanup pass after both PRs land if useful.

Related

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

This PR closes B5. Remaining B-series children: B6 (sqlFile/include classpath URIs), B7 (SQLFile checksum), B8 (XML SAX secure-mode opt-out), B9 (DATABASECHANGELOG INSERT inlining), B10 (informational).

…ourceComparator classes

The <includeAll> changelog directive accepts resourceFilter and
resourceComparator attributes that specify fully-qualified Java class
names. Both are loaded via Class.forName(name, true,
ContextClassLoader).getConstructor().newInstance() with no allowlist
and no marker-interface check before construction:

  // DatabaseChangeLog.java:641
  resourceFilter = (IncludeAllFilter) Class.forName(resourceFilterDef,
      true, Thread.currentThread().getContextClassLoader())
      .getConstructor().newInstance();

  // DatabaseChangeLog.java:731
  resourceComparator = (Comparator<String>) Class.forName(
      resourceComparatorDef, true,
      Thread.currentThread().getContextClassLoader())
      .getConstructor().newInstance();

The initialize=true argument fires the named class's static <clinit>
initializer AT LOAD TIME — before the cast or any other safety check
could reject the load. Any class on the JVM classpath is reachable
via a malicious <includeAll>: name the class, get its static-init
code and zero-arg ctor invoked as a side effect (CWE-470: Use of
Externally-Controlled Input to Select Classes or Code, aka Unsafe
Reflection).

Same surface as <customChange> (DAT-23016 / PR #7748) and
<customPrecondition> (DAT-23017 / PR #7749), gated by
liquibase.allowCustomChange. The audit author explicitly suggested
unifying B2/B3/B5 under one flag. After deliberation: a SEPARATE
flag here for two reasons:

1. <includeAll> is not a "custom-*" element. Extending
   liquibase.allowCustomChange to gate it would stretch the flag
   name beyond what an operator reading 'allowCustomChange' would
   reasonably expect.

2. Distinct flags give operators independent control surface — an
   embedder who uses customChange but not includeAll filters can
   lock down each independently.

To prevent the "operator missed a flag" footgun that motivated #7749
sharing #7748's flag, this PR's new-flag description explicitly
cross-references liquibase.allowCustomChange: operators reading
either description learn that fully locking down changelog-controlled
class loading requires setting BOTH flags to false.

Three pieces:

1. New GlobalConfiguration.ALLOW_INCLUDE_ALL_CLASSES flag
   (liquibase.allowIncludeAllClasses). Boolean, defaults to true.
   Description references CWE-470 and cross-references
   liquibase.allowCustomChange.

2. Two inline gates in DatabaseChangeLog:

   (a) In handleIncludeAll() at the resourceFilter Class.forName
       call site (line 641 in the audit reference). Throws
       SetupException with the configured-off message BEFORE the
       Class.forName, so the static initializer of the named class
       never fires when the flag is false.

   (b) In determineResourceComparator() at the resourceComparator
       Class.forName call site (line 731 in the audit reference).
       Throws UnexpectedLiquibaseException (the method has no
       checked-throws clause). Critically, the gate fires BEFORE
       the try/catch block — the existing
       ReflectiveOperationException-catches-and-falls-back-to-
       standard logic would otherwise silently degrade the
       configured-off intent into the default comparator (which
       would mask the rejection from the operator and look identical
       to the success path in logs).

3. Five new Spock specs in DatabaseChangeLogTest (195 pre-existing
   → 200 total):

   - determineResourceComparator returns the standard comparator
     when allowIncludeAllClasses=true (default) and
     resourceComparatorDef is null. Default-path preservation.
     Behaviour-asserts the comparator (comparator.compare(...))
     rather than reference-equality because
     getStandardChangeLogComparator() returns a fresh lambda each
     call.

   - determineResourceComparator falls back to standard comparator
     at default flag when resourceComparator class fails to load.
     Pins the pre-fix catch behaviour — flag=true must not regress
     the existing graceful-fallback path.

   - determineResourceComparator throws UnexpectedLiquibaseException
     BEFORE Class.forName when allowIncludeAllClasses=false. Uses
     a non-existent class name to prove the gate ran first: pre-fix
     code path would have produced a fallback to the standard
     comparator (no exception); after-fix produces ONLY the
     configured-off error — the loader never ran. Asserts message
     shape (flag-name in both directions + "NOT loaded" +
     "resourceComparator") as proof-of-short-circuit.

   - determineResourceComparator with null resourceComparatorDef
     returns standard comparator even when
     allowIncludeAllClasses=false. Pins that the gate does NOT
     over-reach into the default-comparator path.

   - handleIncludeAll throws SetupException BEFORE Class.forName
     when allowIncludeAllClasses=false and resourceFilter is
     specified. Parser-path test exercising the inline gate at
     line 641 via the standard load() entry point with a
     ParsedNode-based changelog.

200/200 DatabaseChangeLogTest specs pass + 11/11 GlobalConfigurationTest.
Broader sanity sweep: 262 tests across DatabaseChangeLogTest (200),
XMLChangeLogSAXParser_RealFile_Test (30), and GlobalConfigurationTest
(11) all pass with the new flag defaulting to true — no behaviour
change for any existing <includeAll> at the default.
@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: e1d64452-c6c7-4e12-a6f0-19c10abe86ad

📥 Commits

Reviewing files that changed from the base of the PR and between 1caee78 and 99b4986.

📒 Files selected for processing (1)
  • liquibase-cli/src/test/groovy/liquibase/integration/commandline/LiquibaseCommandLineTest.groovy
✅ Files skipped from review due to trivial changes (1)
  • liquibase-cli/src/test/groovy/liquibase/integration/commandline/LiquibaseCommandLineTest.groovy

📝 Walkthrough

Walkthrough

This PR adds a configurable security gate that prevents unsafe reflective class loading in Liquibase changelog includeAll directives. A new global flag allowIncludeAllClasses (default true) gates class instantiation for resourceFilter and resourceComparator parameters, enforcing validation before any reflective loading occurs.

Changes

Class-Loading Validation Gate

Layer / File(s) Summary
Configuration flag definition
liquibase-standard/src/main/java/liquibase/GlobalConfiguration.java
ALLOW_INCLUDE_ALL_CLASSES is declared and registered as a global boolean flag with description about unsafe class-loading surfaces when set to false, defaulting to true.
Class-loading validation gates
liquibase-standard/src/main/java/liquibase/changelog/DatabaseChangeLog.java
Configuration checks are added to handleIncludeAll and determineResourceComparator before any reflective class loading, throwing exceptions when the flag is disabled and class names are provided.
Validation test cases
liquibase-standard/src/test/groovy/liquibase/changelog/DatabaseChangeLogTest.groovy
Spock tests validate default flag behavior, fallback on class-load failure when enabled, exception throwing when disabled, null-comparator edge cases, and integration error messaging for resourceFilter.
CLI help snapshot update
liquibase-cli/src/test/groovy/liquibase/integration/commandline/LiquibaseCommandLineTest.groovy
Updates expected help output to include the new --allow-include-all-classes global CLI option with default and environment/defaults-file mappings.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • liquibase/liquibase#7760: Updates to LiquibaseCommandLineTest expected help output for a different --allow-* global flag; similar CLI help snapshot changes.

Suggested labels

TypeEnhancement, TypeSupport

Suggested reviewers

  • wwillard7800
  • filipelautert
  • abrackx
🚥 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 clearly and specifically describes the primary change: introducing an opt-in restricted mode for unsafe class loading in the includeAll directive, addressing CWE-470.
Description check ✅ Passed The PR description is comprehensive and well-structured, covering Impact, Fix details, test specs, and Things to be aware of. However, the Release note section required by the template is not filled in.
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-include-all-classes-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 40s —— View job


Code Review

Security Gates (DatabaseChangeLog.java)

✅ LGTM — Gates fire BEFORE Class.forName() calls to prevent static initializer execution (lines 645 and 747). This is the critical defense against CWE-470. Both use the correct defensive pattern: !Boolean.TRUE.equals(GlobalConfiguration.ALLOW_INCLUDE_ALL_CLASSES.getCurrentValue()) handles null/unset values by gating (fires when null → true, disabled, or unset).

Exception Handling

✅ LGTMSetupException in handleIncludeAll() (line 646) matches the method's throws clause; UnexpectedLiquibaseException in determineResourceComparator() (line 748) correctly fires BEFORE the try/catch block, preventing the existing ReflectiveOperationException catch from silently degrading the configured-off intent into the default comparator.

Configuration Definition (GlobalConfiguration.java)

✅ LGTM — Description cross-references liquibase.allowCustomChange to help operators understand the broader changelog-controlled class loading surface. Default value true preserves existing behavior. The detailed CWE-470 explanation is appropriate for a security flag.

Test Coverage

✅ LGTM — Five specs correctly verify: (1) null default path at flag=true, (2) fallback graceful degradation when class load fails + flag=true, (3) gate fires BEFORE Class.forName when flag=false using non-existent class, (4) gate doesn't over-reach when resourceComparator is null even with flag=false, (5) parser path via load() entry point. Tests use behavior assertions (comparator.compare(...)) instead of reference equality since getStandardChangeLogComparator() returns fresh lambdas.

Error Messages

✅ LGTM — Messages include the flag name in both enabled/disabled forms and explicitly state "NOT loaded" to prove the gate fired pre-load. This prevents operator confusion about whether the class was attempted.


Summary: Well-implemented security fix with appropriate gate placement, correct exception handling per method signature, and comprehensive test coverage. No significant issues found.

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 #7754 (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: both Class.forName sinks (resourceFilter + resourceComparator) gated before the loader fires, comparator gate is outside the existing try { ... } catch (ReflectiveOperationException) so the graceful-fallback can't silently swallow the configured-off intent. Ship it.

…put for the new allowIncludeAllClasses flag

This PR introduces ALLOW_INCLUDE_ALL_CLASSES in GlobalConfiguration
(allowIncludeAllClasses=true by default), governing the includeAll
resourceFilter/resourceComparator class-name attributes. The help-output
snapshot reflects over all registered config definitions, so the new flag
appears in --help and the fixture needs the matching block.

Block captured byte-exact from picocli's actual --help output, inserted
alphabetically between --allow-execute-command and --allow-inherit-logical-
file-path. Mirrors the #7760 pattern for the three already-merged ALLOW_*
audit flags (the merge from origin/main brought those in; this commit adds
the fourth).

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

claude Bot commented May 28, 2026

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


Code Review

Security Gate Implementation (DatabaseChangeLog.java)

LGTM — Both gates fire BEFORE Class.forName() to prevent static initializer execution, which is critical for CWE-470. Null-safe pattern !Boolean.TRUE.equals(...) correctly handles unset flags. Exception handling matches method signatures: SetupException for handleIncludeAll() and UnexpectedLiquibaseException positioned BEFORE try/catch in determineResourceComparator() to prevent silent degradation.

Configuration Flag (GlobalConfiguration.java)

LGTM — New ALLOW_INCLUDE_ALL_CLASSES flag defaults to true (preserves existing behavior), includes detailed description mentioning CWE-470, and cross-references liquibase.allowCustomChange to help operators understand the full changelog-controlled class loading surface.

Test Coverage (DatabaseChangeLogTest.groovy)

LGTM — Five comprehensive specs validate: default path preservation, graceful fallback on load failure with flag=true, gate firing BEFORE Class.forName when flag=false using non-existent class name, gate not over-reaching when resourceComparator is null, and integration via parser load() entry point. All tests use behavior assertions rather than reference equality.

Help Text Update (LiquibaseCommandLineTest.groovy)

LGTM — CLI help output correctly documents the new flag with full description matching GlobalConfiguration, maintaining consistency.

No significant issues found. Ship it!


@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 28, 2026

Actionable comments posted: 0

v-petrovych added a commit that referenced this pull request May 28, 2026
…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.
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