CWE-470: opt-in restricted mode for includeAll resourceFilter and resourceComparator classes#7766
CWE-470: opt-in restricted mode for includeAll resourceFilter and resourceComparator classes#7766v-petrovych wants to merge 3 commits into
Conversation
…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.
|
|
|
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)
✅ Files skipped from review due to trivial changes (1)
📝 WalkthroughWalkthroughThis PR adds a configurable security gate that prevents unsafe reflective class loading in Liquibase changelog ChangesClass-Loading Validation Gate
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 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 40s —— View job Code ReviewSecurity Gates (DatabaseChangeLog.java)✅ LGTM — Gates fire BEFORE Exception Handling✅ LGTM — Configuration Definition (GlobalConfiguration.java)✅ LGTM — Description cross-references 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 Error Messages✅ LGTM — Messages include the flag name in both enabled/disabled forms and explicitly state Summary: Well-implemented security fix with appropriate gate placement, correct exception handling per method signature, and comprehensive test coverage. No significant issues found. |
filipelautert
left a comment
There was a problem hiding this comment.
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.
…lasses-opt-out-gate
…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 finished @v-petrovych's task in 46s —— View job Code ReviewSecurity Gate Implementation (DatabaseChangeLog.java)✅ LGTM — Both gates fire BEFORE Configuration Flag (GlobalConfiguration.java)✅ LGTM — New 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 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! |
|
Actionable comments posted: 0 |
…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.
Impact
The
<includeAll>changelog directive acceptsresourceFilterandresourceComparatorattributes that specify fully-qualified Java class names. Both are loaded viaClass.forName(name, true, ContextClassLoader).getConstructor().newInstance()with no allowlist and no marker-interface check before construction:The
initialize=trueargument 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:<includeAll>is not a "custom-*" element. Extendingliquibase.allowCustomChange(which gates<customChange>and<customPrecondition>) to also gate<includeAll>would stretch the flag name beyond what an operator readingallowCustomChangewould reasonably expect.<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 tofalse. 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_CLASSESflag —liquibase.allowIncludeAllClasses, Boolean, defaults totrue. Description references CWE-470 and cross-referencesliquibase.allowCustomChange.2. Two inline gates in
DatabaseChangeLog:(a) In
handleIncludeAll()at theresourceFilterClass.forNamecall site (line 641 in the audit reference). ThrowsSetupExceptionwith the configured-off message BEFORE theClass.forName, so the static initializer of the named class never fires when the flag isfalse.(b) In
determineResourceComparator()at theresourceComparatorClass.forNamecall site (line 731 in the audit reference). ThrowsUnexpectedLiquibaseException(the method has no checked-throws clause). Critically, the gate fires BEFORE thetry/catchblock — the existingReflectiveOperationException-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 nullcomparator.compare(...)rather than reference-equality becausegetStandardChangeLogComparator()returns a freshComparator.comparing(...)lambda each call.determineResourceComparator falls back to standard comparator at default flag when resourceComparator class fails to load (preserves pre-fix catch behaviour)determineResourceComparator throws UnexpectedLiquibaseException BEFORE Class.forName when allowIncludeAllClasses=false"NOT loaded"+"resourceComparator") is the proof-of-short-circuit.determineResourceComparator with null resourceComparatorDef returns standard comparator even when allowIncludeAllClasses=falsehandleIncludeAll throws SetupException BEFORE Class.forName when allowIncludeAllClasses=false and resourceFilter is specifiedload()entry point with aParsedNode-based changelog.Broader sanity sweep: 262 tests across
DatabaseChangeLogTest(200),XMLChangeLogSAXParser_RealFile_Test(30), andGlobalConfigurationTest(11) — 0 failures, 1 platform-conditional skip.Things to be aware of
<includeAll>users see no behaviour change.determineResourceComparatorispublic. 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.liquibase.allowCustomChange). The reverse cross-reference (updatingliquibase.allowCustomChangeto 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 #7748 → CWE-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 (
sdoulabel). Direct siblings:executeCommandopt-out gate) — mergedcustomChangeopt-out gate) — mergedcustomPreconditionopt-out gate) — open, approved by @filipelautertsqlCheckopt-out gate) — openThis 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).