diff --git a/src/main/java/com/sparrowwallet/sparrow/io/db/DbPersistence.java b/src/main/java/com/sparrowwallet/sparrow/io/db/DbPersistence.java index 6b362fce..3c842b8e 100644 --- a/src/main/java/com/sparrowwallet/sparrow/io/db/DbPersistence.java +++ b/src/main/java/com/sparrowwallet/sparrow/io/db/DbPersistence.java @@ -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 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 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 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 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 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 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 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 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 { private final ByteArrayOutputStream payload; diff --git a/src/test/java/com/sparrowwallet/sparrow/io/DbPersistenceTest.java b/src/test/java/com/sparrowwallet/sparrow/io/DbPersistenceTest.java new file mode 100644 index 00000000..fcf46478 --- /dev/null +++ b/src/test/java/com/sparrowwallet/sparrow/io/DbPersistenceTest.java @@ -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")); + } +}