mirror of
https://github.com/sparrowwallet/sparrow.git
synced 2026-10-05 19:18:26 +00:00
persistence hardening followup
This commit is contained in:
@@ -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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user