Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions Core/Resgrid.Model/Providers/IRabbitInboundEventProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ namespace Resgrid.Model.Providers
public interface IRabbitInboundEventProvider
{
Task Start(string clientName, string queueName);

/// <summary>
/// True when the consumer channel exists and is open. Hosts poll this to detect a dead
/// consumer (e.g. after the shared connection was force-reset) and re-call Start.
/// </summary>
bool IsConnected();
void RegisterForEvents(Func<int, string, Task> personnelStatusChanged,
Func<int, string, Task> unitStatusChanged,
Func<int, string, Task> callStatusChanged,
Expand Down
87 changes: 71 additions & 16 deletions Core/Resgrid.Services/EncryptionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,28 @@
namespace Resgrid.Services
{
/// <summary>
/// AES-256-CBC encryption service using PBKDF2-HMAC-SHA256 key derivation.
/// AES-256-GCM authenticated encryption service using PBKDF2-HMAC-SHA256 key derivation.
/// The PBKDF2 iteration count defaults to 600,000 per OWASP guidance and is
/// configurable via <see cref="SecurityConfig.Pbkdf2Iterations"/>.
/// Can be used anywhere in the system that requires encryption at rest.
///
/// Payload format: new ciphertexts are "enc2:" + Base64(nonce | tag | cipher). Inputs without
/// that prefix are decrypted via the legacy AES-256-CBC/PKCS7 path so data already at rest
/// (department credentials such as Twilio AccountSid/AuthToken JSON) keeps decrypting. The
/// prefix is applied to the Base64 string rather than a leading payload byte because ':' can
/// never appear in Base64 output — a legacy ciphertext (whose first bytes are a random IV)
/// can therefore never be misdetected as GCM, and no try-one-then-the-other fallback is
/// needed, which keeps wrong-key GCM failures deterministic (the tag check always throws).
/// Legacy payloads migrate to GCM whenever a caller re-encrypts; the service itself has no
/// storage access, so opportunistic re-encrypt-on-read is a caller concern.
/// </summary>
public class EncryptionService : IEncryptionService
{
private const int KeySize = 32; // 256-bit
private const int IvSize = 16; // 128-bit block
private const int IvSize = 16; // 128-bit block (legacy CBC)
private const int NonceSize = 12; // GCM standard nonce
private const int TagSize = 16; // GCM authentication tag
private const string GcmPrefix = "enc2:";

public string Encrypt(string plainText)
{
Expand Down Expand Up @@ -53,26 +66,68 @@ public string DecryptForDepartment(string cipherText, int departmentId, string d

private static string EncryptWithKey(string plainText, byte[] key)
{
using var aes = Aes.Create();
aes.KeySize = 256;
aes.BlockSize = 128;
aes.Mode = CipherMode.CBC;
aes.Padding = PaddingMode.PKCS7;
aes.GenerateIV();

using var encryptor = aes.CreateEncryptor(key, aes.IV);
var nonce = RandomNumberGenerator.GetBytes(NonceSize);
var plainBytes = Encoding.UTF8.GetBytes(plainText);
var cipherBytes = encryptor.TransformFinalBlock(plainBytes, 0, plainBytes.Length);
var cipherBytes = new byte[plainBytes.Length];
var tag = new byte[TagSize];

// Prepend IV so we can recover it on decryption
var result = new byte[IvSize + cipherBytes.Length];
Buffer.BlockCopy(aes.IV, 0, result, 0, IvSize);
Buffer.BlockCopy(cipherBytes, 0, result, IvSize, cipherBytes.Length);
using (var aes = new AesGcm(key, TagSize))
aes.Encrypt(nonce, plainBytes, cipherBytes, tag);

return Convert.ToBase64String(result);
var result = new byte[NonceSize + TagSize + cipherBytes.Length];
Buffer.BlockCopy(nonce, 0, result, 0, NonceSize);
Buffer.BlockCopy(tag, 0, result, NonceSize, TagSize);
Buffer.BlockCopy(cipherBytes, 0, result, NonceSize + TagSize, cipherBytes.Length);

return GcmPrefix + Convert.ToBase64String(result);
}

private static string DecryptWithKey(string cipherText, byte[] key)
{
if (cipherText.StartsWith(GcmPrefix, StringComparison.Ordinal))
return DecryptGcm(cipherText.Substring(GcmPrefix.Length), key);

return DecryptLegacyCbc(cipherText, key);
}

private static string DecryptGcm(string base64Payload, byte[] key)
{
byte[] fullBytes;
try
{
fullBytes = Convert.FromBase64String(base64Payload);
}
catch (FormatException ex)
{
throw new CryptographicException("Cipher text is not valid Base64.", ex);
}

if (fullBytes.Length < NonceSize + TagSize)
throw new CryptographicException("Cipher text is too short to contain a valid nonce and tag.");

var nonce = new byte[NonceSize];
var tag = new byte[TagSize];
var cipherBytes = new byte[fullBytes.Length - NonceSize - TagSize];
Buffer.BlockCopy(fullBytes, 0, nonce, 0, NonceSize);
Buffer.BlockCopy(fullBytes, NonceSize, tag, 0, TagSize);
Buffer.BlockCopy(fullBytes, NonceSize + TagSize, cipherBytes, 0, cipherBytes.Length);

var plainBytes = new byte[cipherBytes.Length];

// A wrong key or any ciphertext/tag tampering fails the tag check and throws
// AuthenticationTagMismatchException (a CryptographicException) — deterministically,
// unlike the legacy CBC padding check.
using (var aes = new AesGcm(key, TagSize))
aes.Decrypt(nonce, cipherBytes, tag, plainBytes);

return Encoding.UTF8.GetString(plainBytes);
}

/// <summary>
/// Decrypts the pre-"enc2:" AES-256-CBC/PKCS7 format (IV | cipher, Base64). Retained so
/// data encrypted before the GCM migration keeps decrypting; never used for new payloads.
/// </summary>
private static string DecryptLegacyCbc(string cipherText, byte[] key)
{
byte[] fullBytes;
try
Expand Down
7 changes: 6 additions & 1 deletion Core/Resgrid.Services/ModerationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -200,11 +200,16 @@ await RecordDepartmentAuditAsync(request, AuditLogTypes.ModerationRequestReopene
}
catch (Exception ex)
{
Logging.LogException(ex);
// A unique-constraint failure here is the expected duplicate-report race: another
// submission from the same reporter won the insert. Only log when no concurrent
// winner exists — that's a real failure being rethrown, not the recovered race.
var concurrent = await _moderationReportRepository.GetByRequestAndReporterAsync(
request.ModerationRequestId, reportedByUserId, useUnitOfWork: false);
if (concurrent == null)
{
Logging.LogException(ex);
throw;
}

_unitOfWork.DiscardChanges();
return concurrent;
Expand Down
80 changes: 64 additions & 16 deletions Providers/Resgrid.Providers.Bus.Rabbit/RabbitConnection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,35 +34,46 @@ public static async Task<bool> VerifyAndCreateClients(string clientName)

try
{
_factory = new ConnectionFactory() { HostName = ServiceBusConfig.RabbitHostname, UserName = ServiceBusConfig.RabbitUsername, Password = ServiceBusConfig.RabbbitPassword };
_connection = await _factory.CreateConnectionAsync(clientName);
}
catch (Exception ex)
{
Logging.LogException(ex);

if (!String.IsNullOrWhiteSpace(ServiceBusConfig.RabbitHostname2))
// Re-check inside the lock: concurrent callers (e.g. several publishers recovering
// through ForceResetAsync at once) all pass the unsynchronized null check above and
// queue on the semaphore. Without this re-check each waiter would create its own
// IConnection in turn — orphaning the previous one undisposed and inflating the
// broker's connection count. Only the first acquirer may create the connection.
if (_connection == null)
{
try
{
_factory = new ConnectionFactory() { HostName = ServiceBusConfig.RabbitHostname2, UserName = ServiceBusConfig.RabbitUsername, Password = ServiceBusConfig.RabbbitPassword };
_factory = new ConnectionFactory() { HostName = ServiceBusConfig.RabbitHostname, UserName = ServiceBusConfig.RabbitUsername, Password = ServiceBusConfig.RabbbitPassword };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules low

Duplicated logic across the connection-factory creation and CreateConnectionAsync sequence (lines 46-47, 57-58, 68-69) complicates maintenance if factory options change. Extract a helper such as static async Task<bool> TryConnectAsync(string hostname, string clientName) to build the factory and attempt connections.

Kody rule violation: Extract duplicated logic into functions

Prompt for LLM

File Providers/Resgrid.Providers.Bus.Rabbit/RabbitConnection.cs:

Line 46:

Duplicated logic across the connection-factory creation and `CreateConnectionAsync` sequence (lines 46-47, 57-58, 68-69) complicates maintenance if factory options change. Extract a helper such as `static async Task<bool> TryConnectAsync(string hostname, string clientName)` to build the factory and attempt connections.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

_connection = await _factory.CreateConnectionAsync(clientName);
}
catch (Exception ex2)
catch (Exception ex)
{
Logging.LogException(ex2);
Logging.LogException(ex);

if (!String.IsNullOrWhiteSpace(ServiceBusConfig.RabbitHostname3))
if (!String.IsNullOrWhiteSpace(ServiceBusConfig.RabbitHostname2))
{
try
{
_factory = new ConnectionFactory() { HostName = ServiceBusConfig.RabbitHostname3, UserName = ServiceBusConfig.RabbitUsername, Password = ServiceBusConfig.RabbbitPassword };
_factory = new ConnectionFactory() { HostName = ServiceBusConfig.RabbitHostname2, UserName = ServiceBusConfig.RabbitUsername, Password = ServiceBusConfig.RabbbitPassword };
_connection = await _factory.CreateConnectionAsync(clientName);
}
catch (Exception ex3)
catch (Exception ex2)
{
Logging.LogException(ex3);
throw;
Logging.LogException(ex2);

if (!String.IsNullOrWhiteSpace(ServiceBusConfig.RabbitHostname3))
{
try
{
_factory = new ConnectionFactory() { HostName = ServiceBusConfig.RabbitHostname3, UserName = ServiceBusConfig.RabbitUsername, Password = ServiceBusConfig.RabbbitPassword };
_connection = await _factory.CreateConnectionAsync(clientName);
}
catch (Exception ex3)
{
Logging.LogException(ex3);
throw;
}
}
}
}
}
Expand Down Expand Up @@ -239,6 +250,43 @@ await channel.QueueDeclareAsync(
}
}

/// <summary>
/// Disposes the shared connection and clears cached state so the next CreateConnection call
/// builds a fresh one. Needed when the connection is open but unusable — e.g. its channel
/// numbers are exhausted (ChannelAllocationException) — which the IsOpen guards can never
/// detect. Raises ConnectionReset so cached declaration state is cleared.
/// </summary>
public static async Task ForceResetAsync()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Performance high

Race condition in VerifyAndCreateClients: the _connection == null check (line 31) lacks a re-check inside the semaphore block, allowing concurrent send threads recovering from ChannelAllocationException to simultaneously create new IConnection instances and exhaust the broker's connection limit. Adding an inner _connection == null re-check inside the critical section ensures only the first thread creates the connection.

if (_connection == null)
{
    await _semaphore.WaitAsync();
    try
    {
        if (_connection == null) // re-check inside the lock: a prior holder may have already created it
        {
            _factory = new ConnectionFactory() { ... };
            _connection = await _factory.CreateConnectionAsync(clientName);
        }
    }
Prompt for LLM

File Providers/Resgrid.Providers.Bus.Rabbit/RabbitConnection.cs:

Line 248:

Race condition in `VerifyAndCreateClients`: the `_connection == null` check (line 31) lacks a re-check inside the semaphore block, allowing concurrent send threads recovering from `ChannelAllocationException` to simultaneously create new `IConnection` instances and exhaust the broker's connection limit. Adding an inner `_connection == null` re-check inside the critical section ensures only the first thread creates the connection.

Suggested Code:

if (_connection == null)
{
    await _semaphore.WaitAsync();
    try
    {
        if (_connection == null) // re-check inside the lock: a prior holder may have already created it
        {
            _factory = new ConnectionFactory() { ... };
            _connection = await _factory.CreateConnectionAsync(clientName);
        }
    }

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

{
IConnection connection;

await _semaphore.WaitAsync();
try
{
connection = _connection;
_connection = null;
_factory = null;
}
finally
{
_semaphore.Release();
}

RaiseConnectionReset();

if (connection != null)
{
try
{
await connection.DisposeAsync();
}
catch (Exception ex)
{
Logging.LogException(ex);
}
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

public static async Task<IConnection> CreateConnection(string clientName)
{
if (_connection == null)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,51 @@ public class RabbitInboundEventProvider : IRabbitInboundEventProvider

public async Task Start(string clientName, string queueName)
{
await VerifyAndCreateClients(clientName);
await StartMonitoring(queueName);
// Dispose any channel from a previous Start (the host watchdog re-calls Start after a
// disconnect). Disposal also removes it from automatic-recovery tracking, so a late
// connection recovery can't resurrect the old consumer alongside the new one and
// double-deliver events.
await DisposeChannelAsync();

if (!await VerifyAndCreateClients(clientName))
return;

// _channel stays null when the connection couldn't be created; skip monitoring so the
// caller sees IsConnected() == false and can retry instead of an NRE killing the task.
if (_channel == null)
return;

try
{
await StartMonitoring(queueName);
}
catch
{
// If consumer registration fails the channel is open but consumes nothing, so
// IsConnected() would report healthy and the host watchdog would never rebuild.
// Tear the channel down (nulled field makes IsConnected() false) and rethrow for
// the caller's retry path.
await DisposeChannelAsync();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules high

Unhandled exception in await DisposeChannelAsync() inside the catch block masks the original StartMonitoring exception and prevents the rethrow from executing. Wrap the cleanup in its own try/catch to handle secondary errors, then throw; the original exception.

Kody rule violation: Handle async operations with proper error handling

Prompt for LLM

File Providers/Resgrid.Providers.Bus.Rabbit/RabbitInboundEventProvider.cs:

Line 59:

Unhandled exception in `await DisposeChannelAsync()` inside the catch block masks the original `StartMonitoring` exception and prevents the rethrow from executing. Wrap the cleanup in its own try/catch to handle secondary errors, then `throw;` the original exception.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

throw;
}
}

private async Task DisposeChannelAsync()
{
var channel = _channel;
_channel = null;

if (channel == null)
return;

try
{
await channel.DisposeAsync();
}
catch (Exception ex)
{
Logging.LogException(ex);
}
}

private async Task<bool> VerifyAndCreateClients(string clientName)
Expand All @@ -55,6 +98,13 @@ private async Task<bool> VerifyAndCreateClients(string clientName)
catch (Exception ex)
{
Framework.Logging.LogException(ex);

// Exhausted channel numbers leave the connection open but unusable, and the host
// watchdog's retry would get the same connection back forever — reset it so the
// next Start builds a fresh one.
if (ex is RabbitMQ.Client.Exceptions.ChannelAllocationException)
await RabbitConnection.ForceResetAsync();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules high

Unhandled exception vulnerability exists in the catch block because RabbitConnection.ForceResetAsync() lacks its own try/catch. If ForceResetAsync throws, the exception escapes the handler and crashes the caller; wrap the await in a nested try/catch and log any secondary failure before returning false.

Kody rule violation: Handle async operations with proper error handling

Prompt for LLM

File Providers/Resgrid.Providers.Bus.Rabbit/RabbitInboundEventProvider.cs:

Line 106:

Unhandled exception vulnerability exists in the catch block because `RabbitConnection.ForceResetAsync()` lacks its own try/catch. If `ForceResetAsync` throws, the exception escapes the handler and crashes the caller; wrap the await in a nested try/catch and log any secondary failure before returning false.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules high

Unhandled exception risk in ForceResetAsync exposes the caller to unhandled network I/O failures propagating from the catch block with no context. Wrap the reset call in a dedicated try/catch, log the secondary exception with context (clientName, operation 'ForceReset'), and continue to return false.

Kody rule violation: Add try-catch blocks for external calls

Prompt for LLM

File Providers/Resgrid.Providers.Bus.Rabbit/RabbitInboundEventProvider.cs:

Line 106:

Unhandled exception risk in `ForceResetAsync` exposes the caller to unhandled network I/O failures propagating from the catch block with no context. Wrap the reset call in a dedicated try/catch, log the secondary exception with context (clientName, operation 'ForceReset'), and continue to return false.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.


return false;
}

Expand Down
Loading
Loading