Skip to content
Draft
Show file tree
Hide file tree
Changes from 4 commits
Commits
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
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package stirling.software.SPDF;

import java.io.IOException;
import java.lang.reflect.InvocationTargetException;
import java.net.URISyntaxException;
import java.nio.file.Files;
import java.nio.file.Path;
Expand Down Expand Up @@ -71,6 +72,7 @@ public static void main(String[] args) throws IOException, InterruptedException
} catch (IOException | URISyntaxException e) {
log.error("Error initialising configuration", e);
}
migrateLegacyH2Database();
Map<String, String> propertyFiles = new HashMap<>();

// External config files
Expand Down Expand Up @@ -126,6 +128,25 @@ public static void main(String[] args) throws IOException, InterruptedException
printStartupLogs();
}

private static void migrateLegacyH2Database() throws IOException {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Introduced reflective startup of H2 migration via Class.forName/method.invoke; prefer explicit wiring to keep startup behaviour transparent

Details

✨ AI Reasoning
​The code attempts to run an optional migration class by loading it reflectively and invoking its migrateIfNeeded method. Reflection and dynamic invocation can obscure behavior from static analysis and reviewers because the class may be absent in some builds (caught ClassNotFoundException) and the invocation path is not visible in normal call graphs. This construct changes startup semantics and was added in this change set, increasing the use of reflection in startup flow which can be used to hide or dynamically alter behavior.

🔧 How do I fix it?
Ensure code is transparent and not intentionally obfuscated. Avoid hiding functionality from code review. Focus on intent and deception, not specific patterns.

Reply @AikidoSec feedback: [FEEDBACK] to get better review comments in the future.
Reply @AikidoSec ignore: [REASON] to ignore this issue.
More info

try {
Class<?> migrationClass =
Class.forName(
"stirling.software.proprietary.security.migration.H2DatabaseMigration");
migrationClass.getMethod("migrateIfNeeded").invoke(null);
} catch (ClassNotFoundException e) {
// Core/ultra-lite builds do not contain the proprietary database module.
} catch (InvocationTargetException e) {
Throwable cause = e.getCause();
if (cause instanceof IOException ioException) {
throw ioException;
}
throw new IOException("Legacy H2 database migration failed", cause);
} catch (ReflectiveOperationException e) {
throw new IOException("Could not start legacy H2 database migration", e);
}
}

@PostConstruct
public void init() {
String backendUrl = appConfig.getBackendUrl();
Expand Down
2 changes: 1 addition & 1 deletion app/core/src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ spring.web.resources.mime-mappings.webmanifest=application/manifest+json
spring.mvc.async.request-timeout=${SYSTEM_CONNECTIONTIMEOUTMILLISECONDS:1200000}
server.jetty.max-http-request-header-size=32768

spring.datasource.url=jdbc:h2:file:./configs/stirling-pdf-DB-2.3.232;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE;MODE=PostgreSQL
spring.datasource.url=jdbc:h2:file:./configs/stirling-pdf-DB-2.4.240;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE;MODE=PostgreSQL
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
Expand Down
74 changes: 73 additions & 1 deletion app/proprietary/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@ repositories {
maven { url = "https://build.shibboleth.net/maven/releases" }
}

configurations {
h2Migration
}

def h2RuntimeVersion = '2.4.240'
def h2MigrationVersion = '2.3.232'
def h2VersionsLockFile = rootProject.file('gradle/h2-versions.lock')

bootRun {
enabled = false
}
Expand Down Expand Up @@ -58,7 +66,8 @@ dependencies {
api "io.jsonwebtoken:jjwt-api:${jwtVersion}"
runtimeOnly "io.jsonwebtoken:jjwt-impl:${jwtVersion}"
runtimeOnly "io.jsonwebtoken:jjwt-jackson:${jwtVersion}"
runtimeOnly 'com.h2database:h2:2.3.232' // Don't upgrade h2database - file format incompatible with 2.4.x, would break existing user databases
runtimeOnly "com.h2database:h2:${h2RuntimeVersion}"
h2Migration "com.h2database:h2:${h2MigrationVersion}"
runtimeOnly 'org.postgresql:postgresql:42.7.11'
implementation('com.coveo:saml-client:5.0.0') {
exclude group: 'org.opensaml', module: 'opensaml-core'
Expand All @@ -80,6 +89,69 @@ dependencies {
testImplementation "org.testcontainers:junit-jupiter:${testcontainersMinioVersion}"
}

tasks.named('processResources') {
from(configurations.h2Migration) {
into 'h2-migration'
}
}

tasks.named('processTestResources') {
from(h2VersionsLockFile)
}

tasks.register('verifyH2VersionsLocked') {
group = 'verification'
description = 'Verifies that the resolved H2 versions match the reviewed compatibility lock.'
inputs.file(h2VersionsLockFile)
inputs.property('runtimeVersion', h2RuntimeVersion)
inputs.property('migrationVersion', h2MigrationVersion)

doLast {
if (!h2VersionsLockFile.isFile()) {
throw new GradleException("Missing H2 compatibility lock: ${h2VersionsLockFile}")
}

Map<String, String> lockedVersions = h2VersionsLockFile.readLines('UTF-8')
.findAll { line -> !line.isBlank() && !line.startsWith('#') }
.collectEntries { line ->
def parts = line.split('=', 2)
if (parts.length != 2 || parts[0].isBlank() || parts[1].isBlank()) {
throw new GradleException("Invalid H2 compatibility lock entry: ${line}")
}
[(parts[0]): parts[1]]
}

Map<String, String> declaredVersions = [
runtime: h2RuntimeVersion,
migration: h2MigrationVersion,
]
if (lockedVersions != declaredVersions) {
throw new GradleException(
"H2 dependency versions changed without updating ${h2VersionsLockFile}. "
+ "Run the H2 migration tests and explicitly review the compatibility lock. "
+ "Expected ${lockedVersions}, declared ${declaredVersions}.")
}

Map<String, String> resolvedVersions = [
runtime: configurations.runtimeClasspath.resolvedConfiguration.resolvedArtifacts
.find { artifact -> artifact.moduleVersion.id.group == 'com.h2database' && artifact.name == 'h2' }
?.moduleVersion?.id?.version,
migration: configurations.h2Migration.resolvedConfiguration.resolvedArtifacts
.find { artifact -> artifact.moduleVersion.id.group == 'com.h2database' && artifact.name == 'h2' }
?.moduleVersion?.id?.version,
]
if (resolvedVersions != declaredVersions) {
throw new GradleException(
"Resolved H2 versions do not match the reviewed compatibility lock. "
+ "Expected ${declaredVersions}, resolved ${resolvedVersions}.")
}
}
}

tasks.named('check') {
dependsOn(tasks.named('verifyH2VersionsLocked'))
}

tasks.register('prepareKotlinBuildScriptModel') {}

tasks.register('type3SignatureTool', JavaExec) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ public DatabaseConfig(
DATASOURCE_DEFAULT_URL =
"jdbc:h2:file:"
+ InstallationPathConfig.getConfigPath()
+ "stirling-pdf-DB-2.3.232;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE;MODE=PostgreSQL";
+ "stirling-pdf-DB-2.4.240;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE;MODE=PostgreSQL";
log.debug("Database URL: {}", DATASOURCE_DEFAULT_URL);
this.datasource = datasource;
this.runningProOrHigher = runningProOrHigher;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ public ResponseEntity<?> importDatabase(
"failedImportFile",
"message",
"Failed to import database: " + e.getMessage()));
} finally {
Files.deleteIfExists(tempTemplatePath);
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
package stirling.software.proprietary.security.migration;

import java.io.IOException;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.net.URL;
import java.net.URLClassLoader;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.Comparator;
import java.util.stream.Stream;

import lombok.extern.slf4j.Slf4j;

import stirling.software.common.configuration.InstallationPathConfig;

/** Converts the embedded H2 database from the 2.3 file format to 2.4. */
@Slf4j
public final class H2DatabaseMigration {

public static final String OLD_DATABASE_NAME = "stirling-pdf-DB-2.3.232";
public static final String NEW_DATABASE_NAME = "stirling-pdf-DB-2.4.240";
private static final String OLD_H2_RESOURCE = "h2-migration/h2-2.3.232.jar";

private H2DatabaseMigration() {}

/** Runs before Spring creates the application DataSource. */
public static void migrateIfNeeded() throws IOException {
Path configDirectory = Path.of(InstallationPathConfig.getConfigPath()).toAbsolutePath();
migrateIfNeeded(configDirectory, findOldH2Jar());
}

static void migrateIfNeeded(Path configDirectory, Path oldH2Jar) throws IOException {
Path oldDatabase = configDirectory.resolve(OLD_DATABASE_NAME + ".mv.db");
Path newDatabase = configDirectory.resolve(NEW_DATABASE_NAME + ".mv.db");

if (!Files.exists(oldDatabase) || Files.exists(newDatabase)) {
return;
}

log.info("Found legacy H2 database {}; migrating to H2 2.4.240", oldDatabase);
Files.createDirectories(configDirectory);
Path workDirectory = Files.createTempDirectory(configDirectory, ".h2-migration-");
Path sourceCopy = workDirectory.resolve(OLD_DATABASE_NAME);
Path script = workDirectory.resolve("database.sql");
Path targetBase = workDirectory.resolve(NEW_DATABASE_NAME);

try {
Files.copy(oldDatabase, sourceCopy.resolveSibling(sourceCopy.getFileName() + ".mv.db"));
exportWithLegacyDriver(sourceCopy, script, oldH2Jar);
importWithCurrentDriver(targetBase, script);
verifyDatabase(targetBase);
Files.move(
targetBase.resolveSibling(targetBase.getFileName() + ".mv.db"),
newDatabase,
StandardCopyOption.REPLACE_EXISTING);
log.info("H2 database migration completed: {}", newDatabase);
} catch (Exception e) {
deleteRecursively(workDirectory);
throw new IOException("Could not migrate the embedded H2 database", unwrap(e));
}

deleteRecursively(workDirectory);
}

private static Path findOldH2Jar() throws IOException {
URL resource = H2DatabaseMigration.class.getClassLoader().getResource(OLD_H2_RESOURCE);
if (resource == null) {
throw new IOException("Bundled H2 2.3.232 migration driver is missing");
}
Path extracted = Files.createTempFile("stirling-h2-2.3.232-", ".jar");
try (var input = resource.openStream()) {
Files.copy(input, extracted, StandardCopyOption.REPLACE_EXISTING);
}
extracted.toFile().deleteOnExit();
return extracted;
}

private static void exportWithLegacyDriver(Path database, Path script, Path oldH2Jar)
throws Exception {
String url =
"jdbc:h2:file:"
+ database.toAbsolutePath()
+ ";IFEXISTS=TRUE;ACCESS_MODE_DATA=r;DB_CLOSE_ON_EXIT=FALSE;MODE=PostgreSQL";
ClassLoader previous = Thread.currentThread().getContextClassLoader();
try (URLClassLoader loader =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Uses URLClassLoader + Thread.setContextClassLoader + reflective invocation of org.h2.tools.Script.process; dynamic loading obscures runtime behavior

Details

✨ AI Reasoning
​The migration class extracts a bundled legacy H2 JAR to a temp file, creates a URLClassLoader over it, sets it as the thread context class loader, and then reflectively looks up and invokes org.h2.tools.Script.process. These steps intentionally divert classloading and invocation into a dynamically-loaded JAR, making it harder to inspect via static analysis and obscuring the executed code path. This exact mechanism was added by the PR, increasing obfuscation-like patterns.

🔧 How do I fix it?
Ensure code is transparent and not intentionally obfuscated. Avoid hiding functionality from code review. Focus on intent and deception, not specific patterns.

Reply @AikidoSec feedback: [FEEDBACK] to get better review comments in the future.
Reply @AikidoSec ignore: [REASON] to ignore this issue.
More info

new URLClassLoader(
new URL[] {oldH2Jar.toUri().toURL()},
ClassLoader.getPlatformClassLoader())) {
Thread.currentThread().setContextClassLoader(loader);
Class<?> scriptTool = Class.forName("org.h2.tools.Script", true, loader);
Method process =
scriptTool.getMethod(
"process",
String.class,
String.class,
String.class,
String.class,
String.class,
String.class);
process.invoke(null, url, "sa", "", script.toAbsolutePath().toString(), "", "");
} catch (InvocationTargetException e) {
throw unwrap(e);
} finally {
Thread.currentThread().setContextClassLoader(previous);
}
}

private static void importWithCurrentDriver(Path database, Path script) throws SQLException {
String url =
"jdbc:h2:file:"
+ database.toAbsolutePath()
+ ";DB_CLOSE_ON_EXIT=FALSE;MODE=PostgreSQL";
try (Connection connection = DriverManager.getConnection(url, "sa", "")) {
try (PreparedStatement statement = connection.prepareStatement("RUNSCRIPT FROM ?")) {
statement.setString(1, script.toAbsolutePath().toString());
statement.execute();
}
}
}

private static void verifyDatabase(Path database) throws SQLException {
String url = "jdbc:h2:file:" + database.toAbsolutePath() + ";IFEXISTS=TRUE";
try (Connection connection = DriverManager.getConnection(url, "sa", "")) {
try (PreparedStatement statement =
connection.prepareStatement("SELECT H2VERSION() FROM DUAL")) {
try (ResultSet result = statement.executeQuery()) {
if (!result.next() || !result.getString(1).startsWith("2.4.")) {
throw new SQLException("Migrated database does not use H2 2.4");
}
}
}
}
}

private static Exception unwrap(Exception exception) {
if (exception instanceof InvocationTargetException invocation
&& invocation.getCause() != null) {
if (invocation.getCause() instanceof Exception cause) {
return cause;
}
return new IOException("Legacy H2 migration failed", invocation.getCause());
}
return exception;
}

private static void deleteRecursively(Path directory) {
try (Stream<Path> paths = Files.walk(directory)) {
paths.sorted(Comparator.reverseOrder())
.forEach(
path -> {
try {
Files.deleteIfExists(path);
} catch (IOException e) {
log.debug(
"Could not remove temporary migration file {}",
path,
e);
}
});
} catch (IOException e) {
log.debug("Could not remove temporary migration directory {}", directory, e);
}
}
}
Loading
Loading