persistence hardening followup

This commit is contained in:
Craig Raw
2026-07-24 09:48:45 +02:00
parent febece9e9b
commit aeb0eeb11a
2 changed files with 168 additions and 23 deletions
@@ -20,6 +20,7 @@ import org.apache.commons.lang3.concurrent.BasicThreadFactory;
import org.flywaydb.core.Flyway;
import org.flywaydb.core.api.FlywayException;
import org.flywaydb.core.api.exception.FlywayValidateException;
import org.h2.api.JavaObjectSerializer;
import org.h2.mvstore.Cursor;
import org.h2.mvstore.MVMap;
import org.h2.mvstore.MVStore;
@@ -28,6 +29,7 @@ import org.h2.mvstore.WriteBuffer;
import org.h2.mvstore.type.DataType;
import org.h2.mvstore.type.LongDataType;
import org.h2.tools.ChangeFileEncryption;
import org.h2.util.JdbcUtils;
import org.jdbi.v3.core.Jdbi;
import org.jdbi.v3.core.h2.H2DatabasePlugin;
import org.jdbi.v3.sqlobject.SqlObjectPlugin;
@@ -64,9 +66,18 @@ public class DbPersistence implements Persistence {
public static final String MIGRATION_RESOURCES_DIR = "com/sparrowwallet/sparrow/sql/";
private static final Pattern JDBC_URL_INJECTION_PATTERN = Pattern.compile(";\\w+=");
private static final String H2_META_TABLE_MAP = "table.0";
private static final Pattern INVALID_SCHEMA_DDL_PATTERN = Pattern.compile("LINKED\\s+TABLE|CREATE\\s+(?:FORCE\\s+)?(?:TRIGGER|ALIAS)", Pattern.CASE_INSENSITIVE);
private static final Pattern INVALID_SCHEMA_DDL_PATTERN = Pattern.compile("LINKED\\s+TABLE|CREATE\\s+(?:FORCE\\s+|OR\\s+REPLACE\\s+)*(?:TRIGGER|ALIAS|AGGREGATE)"
+ "|\\bENGINE\\s+[\"'\\w]|\\b(?:FILE_READ|FILE_WRITE|CSVWRITE|CSVREAD|RUNSCRIPT|LINK_SCHEMA)\\b", Pattern.CASE_INSENSITIVE);
private static final Pattern WALLET_SCHEMA_IDENTIFIER_PATTERN = Pattern.compile("\"wallet_[^\"\\x00-\\x1f]*\"");
private static final Map<String, String> VALID_COLUMN_DEFAULTS = Map.of("UTXOMIXDATA.MIXESDONE", "0", "FLYWAY_SCHEMA_HISTORY.INSTALLED_ON", "CURRENT_TIMESTAMP");
private static final String H2_BASE_TABLE_CLASS = "org.h2.mvstore.db.MVTable";
private static final String H2_ALLOWED_CLASSES_PROPERTY = "h2.allowedClasses";
private static final String H2_NO_ALLOWED_CLASSES = "com.sparrowwallet.sparrow.NONE";
static {
System.setProperty(H2_ALLOWED_CLASSES_PROPERTY, H2_NO_ALLOWED_CLASSES);
JdbcUtils.serializer = new NoDeserializationSerializer();
}
private HikariDataSource dataSource;
private AsymmetricKeyDeriver keyDeriver;
@@ -93,27 +104,32 @@ public class DbPersistence implements Persistence {
public WalletAndKey loadWallet(Storage storage, CharSequence password, ECKey alreadyDerivedKey) throws IOException, StorageException {
ECKey encryptionKey = getEncryptionKey(password, storage.getWalletFile(), alreadyDerivedKey);
validateStore(storage, encryptionKey);
validateSchema(storage, MASTER_SCHEMA, encryptionKey);
migrate(storage, MASTER_SCHEMA, encryptionKey);
validateSchema(storage, MASTER_SCHEMA, encryptionKey);
try {
validateStore(storage, encryptionKey);
validateSchema(storage, MASTER_SCHEMA, encryptionKey);
migrate(storage, MASTER_SCHEMA, encryptionKey);
validateSchema(storage, MASTER_SCHEMA, encryptionKey);
Jdbi jdbi = getJdbi(storage, getFilePassword(encryptionKey));
masterWallet = jdbi.withHandle(handle -> {
WalletDao walletDao = handle.attach(WalletDao.class);
return walletDao.getMainWallet(MASTER_SCHEMA, getWalletName(storage.getWalletFile(), null));
});
Jdbi jdbi = getJdbi(storage, getFilePassword(encryptionKey));
masterWallet = jdbi.withHandle(handle -> {
WalletDao walletDao = handle.attach(WalletDao.class);
return walletDao.getMainWallet(MASTER_SCHEMA, getWalletName(storage.getWalletFile(), null));
});
if(masterWallet == null) {
throw new StorageException("The wallet file was corrupted. Check the backups folder for previous copies.");
if(masterWallet == null) {
throw new StorageException("The wallet file was corrupted. Check the backups folder for previous copies.");
}
Map<WalletAndKey, Storage> childWallets = loadChildWallets(storage, masterWallet, encryptionKey);
masterWallet.setChildWallets(childWallets.keySet().stream().map(WalletAndKey::getWallet).collect(Collectors.toList()));
createUpdateExecutor(masterWallet);
return new WalletAndKey(masterWallet, encryptionKey, keyDeriver, childWallets);
} catch(StorageException | RuntimeException e) {
closeDataSource();
throw e;
}
Map<WalletAndKey, Storage> childWallets = loadChildWallets(storage, masterWallet, encryptionKey);
masterWallet.setChildWallets(childWallets.keySet().stream().map(WalletAndKey::getWallet).collect(Collectors.toList()));
createUpdateExecutor(masterWallet);
return new WalletAndKey(masterWallet, encryptionKey, keyDeriver, childWallets);
}
private Map<WalletAndKey, Storage> loadChildWallets(Storage storage, Wallet masterWallet, ECKey encryptionKey) throws StorageException {
@@ -435,8 +451,10 @@ public class DbPersistence implements Persistence {
builder.encryptionKey(filePassword.toCharArray());
}
boolean storeOpened = false;
byte[] metaPayload = null;
try(MVStore store = builder.open()) {
storeOpened = true;
if(store.getMapNames().contains(H2_META_TABLE_MAP)) {
ByteArrayOutputStream payload = new ByteArrayOutputStream();
PageCapture capture = new PageCapture(payload);
@@ -449,15 +467,18 @@ public class DbPersistence implements Persistence {
metaPayload = payload.toByteArray();
}
} catch(MVStoreException e) {
if(metaPayload == null) {
log.debug("Could not read wallet file store for validation, deferring to standard open", e);
if(metaPayload != null) {
log.warn("Error closing wallet file store after validation", e);
} else if(!storeOpened) {
log.debug("Could not open wallet file store for validation, deferring to standard open", e);
return;
} else {
throw new StorageException("This is not a valid wallet file.\n\nWallet file could not be validated.");
}
log.warn("Error closing wallet file store after validation", e);
}
if(metaPayload == null) {
return;
throw new StorageException("This is not a valid wallet file.\n\nWallet file could not be validated.");
}
String rawDdl = new String(metaPayload, StandardCharsets.ISO_8859_1);
@@ -516,6 +537,12 @@ public class DbPersistence implements Persistence {
throw new RuntimeException(new StorageException("Wallet file contains unexpected linked tables: " + String.join(", ", linkedTables) + "."));
}
List<String> engineTables = handle.createQuery("SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE UPPER(TABLE_SCHEMA) = UPPER(:schema) "
+ "AND TABLE_TYPE = 'BASE TABLE' AND (TABLE_CLASS IS NULL OR TABLE_CLASS <> :tableClass)").bind("schema", schema).bind("tableClass", H2_BASE_TABLE_CLASS).mapTo(String.class).list();
if(!engineTables.isEmpty()) {
throw new RuntimeException(new StorageException("Wallet file contains unexpected table storage engines: " + String.join(", ", engineTables) + "."));
}
List<String> synonyms = handle.createQuery("SELECT SYNONYM_NAME FROM INFORMATION_SCHEMA.SYNONYMS WHERE UPPER(SYNONYM_SCHEMA) = UPPER(:schema)")
.bind("schema", schema).mapTo(String.class).list();
if(!synonyms.isEmpty()) {
@@ -546,6 +573,18 @@ public class DbPersistence implements Persistence {
if(!domains.isEmpty()) {
throw new RuntimeException(new StorageException("Wallet file contains unexpected database domains: " + String.join(", ", domains) + "."));
}
List<String> constants = handle.createQuery("SELECT CONSTANT_NAME FROM INFORMATION_SCHEMA.CONSTANTS WHERE CONSTANT_SCHEMA <> 'INFORMATION_SCHEMA'").mapTo(String.class).list();
if(!constants.isEmpty()) {
throw new RuntimeException(new StorageException("Wallet file contains unexpected database constants: " + String.join(", ", constants) + "."));
}
List<String> serializedColumns = handle.createQuery("SELECT TABLE_NAME || '.' || COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE UPPER(TABLE_SCHEMA) = UPPER(:schema) AND DATA_TYPE = 'JAVA_OBJECT' "
+ "UNION SELECT OBJECT_NAME FROM INFORMATION_SCHEMA.ELEMENT_TYPES WHERE UPPER(OBJECT_SCHEMA) = UPPER(:schema) AND OBJECT_TYPE = 'TABLE' AND DATA_TYPE = 'JAVA_OBJECT'")
.bind("schema", schema).mapTo(String.class).list();
if(!serializedColumns.isEmpty()) {
throw new RuntimeException(new StorageException("Wallet file contains unexpected serialized object columns: " + String.join(", ", serializedColumns) + "."));
}
});
} catch(RuntimeException e) {
if(e.getCause() instanceof StorageException) {
@@ -1029,6 +1068,18 @@ public class DbPersistence implements Persistence {
}
}
private static class NoDeserializationSerializer implements JavaObjectSerializer {
@Override
public byte[] serialize(Object obj) {
throw new UnsupportedOperationException("Serialization of Java objects is not supported in wallet files.");
}
@Override
public Object deserialize(byte[] bytes) {
throw new UnsupportedOperationException("Deserialization of Java objects is not supported in wallet files.");
}
}
private static class PageCapture implements DataType<Object> {
private final ByteArrayOutputStream payload;
@@ -0,0 +1,94 @@
package com.sparrowwallet.sparrow.io;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.File;
import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.Statement;
import java.util.Comparator;
public class DbPersistenceTest {
private Path tempDir;
@BeforeEach
public void setUp() throws Exception {
tempDir = Files.createTempDirectory("sprw-persistence");
}
@AfterEach
public void tearDown() throws Exception {
if(tempDir != null) {
Files.walk(tempDir).sorted(Comparator.reverseOrder()).map(Path::toFile).forEach(File::delete);
}
}
private File buildWalletFile(String... schemaObjects) throws Exception {
String base = tempDir.resolve("wallet").toString();
try(Connection connection = DriverManager.getConnection("jdbc:h2:" + base + ";DATABASE_TO_UPPER=false;DB_CLOSE_ON_EXIT=FALSE", "sa", ""); Statement statement = connection.createStatement()) {
statement.execute("create schema wallet_master");
statement.execute("create table wallet_master.wallet(id identity not null, name varchar(255) not null)");
for(String ddl : schemaObjects) {
statement.execute(ddl);
}
}
return new File(base + ".mv.db");
}
private void assertRejected(File walletFile) {
Storage storage = new Storage(PersistenceType.DB, walletFile);
Assertions.assertThrows(StorageException.class, storage::loadUnencryptedWallet);
}
@Test
public void linkedTableRejectedWithoutExecuting() throws Exception {
File marker = tempDir.resolve("output.csv").toFile();
String target = "jdbc:h2:" + tempDir.resolve("external") + ";INIT=CREATE TABLE IF NOT EXISTS PUB(ID INT)\\;CALL CSVWRITE('" + marker.getAbsolutePath() + "','SELECT 1')";
File walletFile = buildWalletFile("create force linked table wallet_master.remote('','" + target.replace("'", "''") + "','sa','','PUB')");
marker.delete();
assertRejected(walletFile);
Assertions.assertFalse(marker.exists(), "linked table connected during load");
}
@Test
public void triggerRejected() throws Exception {
assertRejected(buildWalletFile("create table wallet_master.t(id int)",
"create force trigger wallet_master.tg before insert on wallet_master.t for each row call \"com.sparrowwallet.Missing\""));
}
@Test
public void generatedColumnRejected() throws Exception {
assertRejected(buildWalletFile("create table wallet_master.g(id int, computed int generated always as (id + 1))"));
}
@Test
public void columnDefaultFunctionRejected() throws Exception {
assertRejected(buildWalletFile("create table wallet_master.d(id int, x int default LENGTH(FILE_READ('/etc/hostname')))"));
}
@Test
public void constantRejected() throws Exception {
assertRejected(buildWalletFile("create constant wallet_master.k value 1"));
}
@Test
public void javaObjectColumnRejected() throws Exception {
assertRejected(buildWalletFile("create table wallet_master.j(id int, obj OTHER)"));
}
@Test
public void checkConstraintRejected() throws Exception {
assertRejected(buildWalletFile("create table wallet_master.c(id int check (LENGTH(FILE_READ('/etc/hostname')) > 0))"));
}
@Test
public void domainRejected() throws Exception {
assertRejected(buildWalletFile("create domain wallet_master.dm as int default 0"));
}
}