fix(configuration): resolve High code-review findings (Configuration-001, Configuration-008)

Configuration-001: wrap the EXEC dbo.sp_ValidateDraft call in
sp_PublishGeneration in a BEGIN TRY/CATCH ROLLBACK; THROW block so a
validation RAISERROR aborts the publish instead of being ignored.

Configuration-008: route caller-supplied strings interpolated into
ConfigAuditLog.DetailsJson through STRING_ESCAPE(@x, 'json') and emit
sp_RollbackToGeneration's @TargetGenerationId as a bare JSON number,
closing the JSON-injection / denial-of-operation vector.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-22 06:12:00 -04:00
parent adf794f791
commit ee51878c08
3 changed files with 115 additions and 9 deletions

View File

@@ -156,6 +156,93 @@ public sealed class StoredProceduresTests
ex.Message.ShouldContain("ReleaseReason is required");
}
/// <summary>
/// Regression for Configuration-001: a draft that fails sp_ValidateDraft must NOT be published.
/// Before the fix, sp_PublishGeneration ran sp_ValidateDraft, ignored its severity-16 RAISERROR,
/// and published the invalid draft anyway.
/// </summary>
[Fact]
public void Publish_aborts_when_ValidateDraft_rejects_the_draft()
{
using var conn = _fixture.OpenConnection();
var (clusterId, _, _, draftId) = SeedClusterWithDraft(conn, suffix: "valbypass");
// Orphan tag — references a DriverInstanceId that does not exist in the generation.
Exec(conn, @"INSERT dbo.Tag (GenerationId, TagId, DriverInstanceId, Name, DataType, AccessLevel, WriteIdempotent, TagConfig)
VALUES (@g, 'tag-1', 'missing-driver', 'X', 'Int32', 'Read', 0, '{}')",
("g", draftId));
var ex = Should.Throw<SqlException>(() =>
Exec(conn, "EXEC dbo.sp_PublishGeneration @ClusterId=@c, @DraftGenerationId=@g",
("c", clusterId), ("g", draftId)));
ex.Message.ShouldContain("unresolved DriverInstanceId");
var status = Scalar<string>(conn,
"SELECT Status FROM dbo.ConfigGeneration WHERE GenerationId = @g", ("g", draftId));
status.ShouldBe("Draft", "an invalid draft must remain Draft after a rejected publish");
}
/// <summary>
/// Regression for Configuration-008: a quote/backslash-bearing value passed to a proc that
/// records DetailsJson must produce well-formed JSON (STRING_ESCAPE), not malformed JSON that
/// fails the CK_ConfigAuditLog_DetailsJson_IsJson check constraint.
/// </summary>
[Fact]
public void RegisterNodeGenerationApplied_escapes_quotes_in_audit_DetailsJson()
{
using var conn = _fixture.OpenConnection();
var (clusterId, nodeId, _, draftId) = SeedClusterWithDraft(conn, suffix: "jsonesc");
Exec(conn, "EXEC dbo.sp_PublishGeneration @ClusterId=@c, @DraftGenerationId=@g",
("c", clusterId), ("g", draftId));
// A status string containing a double-quote and a backslash — would break naive concatenation.
const string hostileStatus = "Applied\"; \\evil";
Should.NotThrow(() =>
Exec(conn, "EXEC dbo.sp_RegisterNodeGenerationApplied @NodeId=@n, @GenerationId=@g, @Status=@s",
("n", nodeId), ("g", draftId), ("s", hostileStatus)));
var detailsJson = Scalar<string>(conn,
@"SELECT TOP 1 DetailsJson FROM dbo.ConfigAuditLog
WHERE EventType = 'NodeApplied' AND NodeId = @n ORDER BY AuditId DESC",
("n", nodeId));
detailsJson.ShouldNotBeNull();
// Round-trip: ISJSON must accept it, and JSON_VALUE must recover the exact original string.
var isValidJson = Scalar<int>(conn, "SELECT ISJSON(@j)", ("j", detailsJson));
isValidJson.ShouldBe(1, "DetailsJson must be well-formed JSON");
var recovered = Scalar<string>(conn, "SELECT JSON_VALUE(@j, '$.status')", ("j", detailsJson));
recovered.ShouldBe(hostileStatus, "the escaped value must round-trip unchanged");
}
/// <summary>
/// Regression for Configuration-008 covering sp_ReleaseExternalIdReservation's @Kind/@Value.
/// </summary>
[Fact]
public void ReleaseReservation_escapes_quotes_in_audit_DetailsJson()
{
using var conn = _fixture.OpenConnection();
// Seed an active reservation with a hostile Value, then release it.
const string hostileValue = "Z\"100\\";
Exec(conn,
@"INSERT dbo.ExternalIdReservation (Kind, Value, EquipmentUuid, ClusterId, FirstPublishedBy, LastPublishedAt)
VALUES ('ZTag', @v, NEWID(), 'cluster-resv-esc', SUSER_SNAME(), SYSUTCDATETIME());",
("v", hostileValue));
Should.NotThrow(() =>
Exec(conn, "EXEC dbo.sp_ReleaseExternalIdReservation @Kind='ZTag', @Value=@v, @ReleaseReason='cleanup'",
("v", hostileValue)));
var detailsJson = Scalar<string>(conn,
@"SELECT TOP 1 DetailsJson FROM dbo.ConfigAuditLog
WHERE EventType = 'ExternalIdReleased' ORDER BY AuditId DESC");
var isValidJson = Scalar<int>(conn, "SELECT ISJSON(@j)", ("j", detailsJson));
isValidJson.ShouldBe(1, "DetailsJson must be well-formed JSON");
var recovered = Scalar<string>(conn, "SELECT JSON_VALUE(@j, '$.value')", ("j", detailsJson));
recovered.ShouldBe(hostileValue, "the escaped value must round-trip unchanged");
}
// ---- helpers ----
/// <summary>Creates a cluster, one node, one credential bound to the current SUSER_SNAME(), and an empty Draft.</summary>