201 lines
9.6 KiB
C#
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)}\"";
|
|
}
|