Files

201 lines
9.6 KiB
C#

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<KnowledgeHubDbContext>();
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<IBackupService>();
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() =>
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<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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<IBackupService>();
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<IRestoreService>();
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => 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)}\"";
}