using System.Globalization; using System.Security.Cryptography; using System.Text; using System.Text.Json; using ItWorx.KnowledgeHub.Application.Operations; using ItWorx.KnowledgeHub.Infrastructure.Operations; using ItWorx.KnowledgeHub.Infrastructure.Persistence; using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Npgsql; namespace ItWorx.KnowledgeHub.IntegrationTests.Operations; [Collection(PostgresTestGroup.Name)] public sealed class TableIdentifierSafetyTests : OwnerSetupTestBase { private readonly PostgresContainerFixture _postgres; public TableIdentifierSafetyTests(PostgresContainerFixture postgres) : base(postgres) { _postgres = postgres; } [Theory] [InlineData("unsafe\";SELECT 1;--")] [InlineData("unsafe';SELECT 1;--")] [InlineData("unsafe;SELECT 1;--")] public async Task Backup_rejects_catalog_identifiers_before_building_copy_sql(string tableName) { using var scope = Factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); await db.Database.OpenConnectionAsync(); var connection = (NpgsqlConnection)db.Database.GetDbConnection(); var quotedName = QuoteIdentifier(tableName); await using (var create = new NpgsqlCommand($"CREATE TABLE public.{quotedName} (id integer)", connection)) await create.ExecuteNonQueryAsync(); try { var backups = scope.ServiceProvider.GetRequiredService(); var exception = await Assert.ThrowsAsync(() => backups.CreateBackupAsync(CancellationToken.None)); Assert.Contains("identificatie", exception.Message, StringComparison.OrdinalIgnoreCase); } finally { await using var drop = new NpgsqlCommand($"DROP TABLE IF EXISTS public.{quotedName}", connection); await drop.ExecuteNonQueryAsync(); } } [Theory] [InlineData("unsafe\";SELECT 1;--")] [InlineData("unsafe';SELECT 1;--")] [InlineData("unsafe;SELECT 1;--")] public async Task Restore_rejects_a_validly_signed_manifest_identifier_before_target_connection(string tableName) { var root = Path.Combine(Path.GetTempPath(), "kh-identifier-" + Guid.NewGuid().ToString("N")); var backupsRoot = Path.Combine(root, "backups"); var backupId = "signed-identifier-test"; var backupDirectory = Path.Combine(backupsRoot, backupId); var keysRoot = Path.Combine(root, "keys"); var attachmentsRoot = Path.Combine(root, "source-attachments"); var targetAttachments = Path.Combine(root, "target-attachments"); Directory.CreateDirectory(backupDirectory); Directory.CreateDirectory(keysRoot); Directory.CreateDirectory(attachmentsRoot); Directory.CreateDirectory(targetAttachments); try { await WriteSignedManifestAsync(backupDirectory, keysRoot, backupId, tableName); var paths = new BackupPaths(backupsRoot, attachmentsRoot, keysRoot); var evidence = new FileBackupEvidenceStore(paths, TimeProvider.System); Assert.NotNull((await evidence.GetAsync(backupId, CancellationToken.None))!.IntegrityVerifiedAt); var restore = new PostgresRestoreService(paths, evidence); var exception = await Assert.ThrowsAsync(() => restore.RestoreAsync( backupId, new RestoreTarget( "Host=127.0.0.1;Port=1;Database=must-not-connect;Username=test;Password=test;Timeout=1", targetAttachments), CancellationToken.None)); Assert.Contains("identificatie", exception.Message, StringComparison.OrdinalIgnoreCase); Assert.Empty(Directory.EnumerateFileSystemEntries(targetAttachments)); } finally { if (Directory.Exists(root)) Directory.Delete(root, recursive: true); } } [Fact] public async Task Restore_rejects_duplicate_signed_manifest_tables_before_target_connection() { var root = Path.Combine(Path.GetTempPath(), "kh-identifier-" + Guid.NewGuid().ToString("N")); var backupsRoot = Path.Combine(root, "backups"); var backupId = "duplicate-table-test"; var backupDirectory = Path.Combine(backupsRoot, backupId); var keysRoot = Path.Combine(root, "keys"); var attachmentsRoot = Path.Combine(root, "source-attachments"); var targetAttachments = Path.Combine(root, "target-attachments"); Directory.CreateDirectory(backupDirectory); Directory.CreateDirectory(keysRoot); Directory.CreateDirectory(attachmentsRoot); Directory.CreateDirectory(targetAttachments); try { await WriteSignedManifestAsync(backupDirectory, keysRoot, backupId, "pages", duplicateTable: true); var paths = new BackupPaths(backupsRoot, attachmentsRoot, keysRoot); var evidence = new FileBackupEvidenceStore(paths, TimeProvider.System); Assert.NotNull((await evidence.GetAsync(backupId, CancellationToken.None))!.IntegrityVerifiedAt); var restore = new PostgresRestoreService(paths, evidence); var exception = await Assert.ThrowsAsync(() => restore.RestoreAsync( backupId, new RestoreTarget( "Host=127.0.0.1;Port=1;Database=must-not-connect;Username=test;Password=test;Timeout=1", targetAttachments), CancellationToken.None)); Assert.Contains("dubbele tabelnamen", exception.Message, StringComparison.OrdinalIgnoreCase); Assert.Empty(Directory.EnumerateFileSystemEntries(targetAttachments)); } finally { if (Directory.Exists(root)) Directory.Delete(root, recursive: true); } } [Fact] public async Task Restore_rejects_unsafe_identity_column_names_before_committing_target_rows() { using var scope = Factory.Services.CreateScope(); var backups = scope.ServiceProvider.GetRequiredService(); var backup = await backups.CreateBackupAsync(CancellationToken.None); await using var target = await RestoreTargetDatabase.CreateAsync(_postgres); var unsafeColumn = QuoteIdentifier("unsafe';SELECT 1;--"); await using (var connection = new NpgsqlConnection(target.ConnectionString)) { await connection.OpenAsync(); await using var create = new NpgsqlCommand( $"CREATE TABLE public.safe_identity_test ({unsafeColumn} integer GENERATED BY DEFAULT AS IDENTITY)", connection); await create.ExecuteNonQueryAsync(); } var restore = scope.ServiceProvider.GetRequiredService(); var exception = await Assert.ThrowsAsync(() => restore.RestoreAsync( backup.BackupId, new RestoreTarget(target.ConnectionString, target.AttachmentsDirectory), CancellationToken.None)); Assert.Contains("identificatie", exception.Message, StringComparison.OrdinalIgnoreCase); await using var verify = new NpgsqlConnection(target.ConnectionString); await verify.OpenAsync(); await using var count = new NpgsqlCommand("SELECT count(*) FROM public.workspaces", verify); Assert.Equal(0L, (long)(await count.ExecuteScalarAsync() ?? 0L)); Assert.Empty(Directory.EnumerateFileSystemEntries(target.AttachmentsDirectory)); } private static async Task WriteSignedManifestAsync( string backupDirectory, string keysRoot, string backupId, string tableName, bool duplicateTable = false) { var table = new BackupTableManifestEntry(tableName, "rows.bin", 0, 0, new string('0', 64)); BackupTableManifestEntry[] tables = duplicateTable ? [table, table] : [table]; var manifest = new BackupManifest( backupId, DateTimeOffset.UtcNow, "ItWorx Knowledge Hub", "1.0", "test", "schema", tables, [], 0); var manifestBytes = JsonSerializer.SerializeToUtf8Bytes(manifest); await File.WriteAllBytesAsync(Path.Combine(backupDirectory, "manifest.json"), manifestBytes); var key = RandomNumberGenerator.GetBytes(32); await File.WriteAllBytesAsync(Path.Combine(keysRoot, "backup-integrity.key"), key); if (!OperatingSystem.IsWindows()) File.SetUnixFileMode(Path.Combine(keysRoot, "backup-integrity.key"), UnixFileMode.UserRead | UnixFileMode.UserWrite); var completedAt = DateTimeOffset.UtcNow; var manifestHash = Convert.ToHexStringLower(SHA256.HashData(manifestBytes)); var payload = string.Join('\n', "itworx-knowledge-hub-backup-evidence-v1", "integrity", backupId, manifestHash, completedAt.UtcTicks.ToString(CultureInfo.InvariantCulture), "1"); var receipt = new { Version = 1, BackupId = backupId, ManifestSha256 = manifestHash, CompletedAt = completedAt, Success = true, HmacSha256 = Convert.ToHexStringLower(HMACSHA256.HashData(key, Encoding.UTF8.GetBytes(payload))) }; await File.WriteAllTextAsync(Path.Combine(backupDirectory, ".integrity.json"), JsonSerializer.Serialize(receipt)); CryptographicOperations.ZeroMemory(key); } private static string QuoteIdentifier(string identifier) => $"\"{identifier.Replace("\"", "\"\"", StringComparison.Ordinal)}\""; }