Skip to content

Commit cc345d3

Browse files
authored
Add option to always hash channel names (#28)
* Always hash channel names as precaution against sql injection * Use channel name length as min length * Try more robust hashing * Add sha256 channel name normalization plus raw prefix * Add channel normalization options * Update readmes to add channel name normalization option * Add timeout to test step * Update lifetime manager class order
1 parent e3d24d1 commit cc345d3

8 files changed

Lines changed: 215 additions & 100 deletions

File tree

.github/workflows/build.yml

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
1-
name: Build
2-
3-
on:
4-
pull_request:
5-
branches: [ "main" ]
6-
7-
permissions:
8-
contents: read
9-
10-
jobs:
11-
build:
1+
name: Build
2+
3+
on:
4+
pull_request:
5+
branches: [ "main" ]
6+
7+
permissions:
8+
contents: read
9+
10+
jobs:
11+
build:
1212
runs-on: ubuntu-latest
1313
name: Build
1414
steps:
@@ -23,3 +23,4 @@ jobs:
2323
run: dotnet build --no-restore
2424
- name: Test
2525
run: dotnet test PostgreSignalR.slnx --no-build --verbosity normal -m:1
26+
timeout-minutes: 10

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ var dataSource = new NpgsqlDataSourceBuilder("<your_postgres_connection_string>"
4343
builder.Services.AddSignalR().AddPostgresBackplane(dataSource, options =>
4444
{
4545
options.Prefix = "myapp";
46+
options.ChannelNameNormaization = ChannelNameNormaization.Truncate;
4647
options.OnInitialized += () => { /* Do something */ }
4748
});
4849
```

README_NUGET.md

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,15 @@ That is all you need to get up and going! PostgreSignalR aims to be very extensi
2222

2323
### Backplane Configuration
2424

25-
You can configure options for the backplane. Options are [documented in the wiki](https://github.com/IanWold/PostgreSignalR/wiki/Options).
25+
You can configure options for the backplane. All of the options are presented blow and [documented in detail in the wiki](https://github.com/IanWold/PostgreSignalR/wiki/Options):
2626

2727
```csharp
2828
var dataSource = new NpgsqlDataSourceBuilder("<your_postgres_connection_string>").Build();
2929
builder.Services.AddSignalR().AddPostgresBackplane(dataSource, options =>
3030
{
3131
options.Prefix = "myapp";
32+
options.ChannelNameNormaization = ChannelNameNormaization.Truncate;
33+
options.OnInitialized += () => { /* Do something */ }
3234
});
3335
```
3436

@@ -42,14 +44,19 @@ builder.Services.AddSignalR()
4244
.AddBackplaneTablePayloadStrategy();
4345
```
4446

45-
The payload table strategy comes with its own configuration options as well:
47+
The payload table strategy comes with its own configuration options as well. All of the options are presented below and [documented in detail in the wiki](https://github.com/IanWold/PostgreSignalR/wiki/Options):
4648

4749
```csharp
4850
builder.Services.AddSignalR()
4951
.AddPostgresBackplane(dataSource)
5052
.AddBackplaneTablePayloadStrategy(options =>
5153
{
52-
options.StorageMode = PostgresBackplanePayloadTableStorage.Always;
54+
options.StorageMode = PostgresBackplanePayloadTableStorage.Auto;
55+
options.SchemaName = "backplane";
56+
options.TableName = "payloads";
57+
options.AutomaticCleanup = true;
58+
options.AutomaticCleanupTtlMs = 1000;
59+
options.AutomaticCleanupIntervalMs = 3600000;
5360
});
5461
```
5562

src/ChannelNameProvider.cs

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
using System.Buffers.Text;
2+
using System.Runtime.CompilerServices;
3+
using System.Security.Cryptography;
4+
using System.Text;
5+
6+
namespace PostgreSignalR;
7+
8+
internal abstract class ChannelNameProvider
9+
{
10+
public ChannelNameProvider(string returnServerName)
11+
{
12+
All = Normalize("all");
13+
GroupManagement = Normalize("internal_groups");
14+
ReturnResults = Normalize($"internal_return_{returnServerName}");
15+
}
16+
17+
/// <summary>
18+
/// Postgres limits channel names to identifiers up to 63 characters.
19+
/// Normalization should ensure any channel names meet this.
20+
/// </summary>
21+
/// <param name="name">The raw name of the channel</param>
22+
/// <returns>The normalized channel name</returns>
23+
internal abstract string Normalize(string name);
24+
25+
/// <summary>
26+
/// Gets the name of the channel for sending to all connections.
27+
/// </summary>
28+
/// <remarks>
29+
/// The payload on this channel is <see cref="PostgresInvocation"/> objects containing
30+
/// invocations to be sent to all connections
31+
/// </remarks>
32+
public string All { get; init; }
33+
34+
/// <summary>
35+
/// Gets the name of the internal channel for group management messages.
36+
/// </summary>
37+
public string GroupManagement { get; }
38+
39+
/// <summary>
40+
/// Gets the name of the internal channel for receiving client results.
41+
/// </summary>
42+
public string ReturnResults { get; }
43+
44+
/// <summary>
45+
/// Gets the name of the channel for sending a message to a specific connection.
46+
/// </summary>
47+
/// <param name="connectionId">The ID of the connection to get the channel for.</param>
48+
[MethodImpl(MethodImplOptions.AggressiveInlining)]
49+
public string Connection(string connectionId) => Normalize($"connection_{connectionId}");
50+
51+
/// <summary>
52+
/// Gets the name of the channel for sending a message to a named group of connections.
53+
/// </summary>
54+
/// <param name="groupName">The name of the group to get the channel for.</param>
55+
[MethodImpl(MethodImplOptions.AggressiveInlining)]
56+
public string Group(string groupName) => Normalize($"group_{groupName}");
57+
58+
/// <summary>
59+
/// Gets the name of the channel for sending a message to all collections associated with a user.
60+
/// </summary>
61+
/// <param name="userId">The ID of the user to get the channel for.</param>32
62+
[MethodImpl(MethodImplOptions.AggressiveInlining)]
63+
public string User(string userId) => Normalize($"user_{userId}");
64+
65+
/// <summary>
66+
/// Gets the name of the acknowledgement channel for the specified server.
67+
/// </summary>
68+
/// <param name="serverName">The name of the server to get the acknowledgement channel for.</param>
69+
[MethodImpl(MethodImplOptions.AggressiveInlining)]
70+
public string Ack(string serverName) => Normalize($"internal_ack_{serverName}");
71+
}
72+
73+
internal sealed class TruncatingChannelNameProvider(string prefix, string returnServerName) : ChannelNameProvider(returnServerName)
74+
{
75+
internal override string Normalize(string name)
76+
{
77+
name = prefix + name;
78+
79+
if (name.Length <= 63)
80+
{
81+
return name;
82+
}
83+
84+
var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(name))).ToLowerInvariant();
85+
var suffix = $"_{hash[..8]}";
86+
var trimLength = Math.Max(63 - suffix.Length, 0);
87+
88+
return $"{name[..trimLength]}{suffix}";
89+
}
90+
}
91+
92+
internal sealed class HashingChannelNameProvider(string prefix, string returnServerName) : ChannelNameProvider(returnServerName)
93+
{
94+
internal override string Normalize(string name)
95+
{
96+
Span<byte> utf8 = stackalloc byte[1024];
97+
var utf8Length = Encoding.UTF8.GetBytes(name.AsSpan(), utf8);
98+
99+
Span<byte> hash = stackalloc byte[32];
100+
SHA256.HashData(utf8[..utf8Length], hash);
101+
102+
Span<byte> base64 = stackalloc byte[44];
103+
Base64.EncodeToUtf8(hash, base64, out _, out var written);
104+
105+
return prefix + string.Create(written-1, base64.ToArray(), static (destination, source) =>
106+
{
107+
for (int i = 0; i < destination.Length; i++)
108+
{
109+
var c = source[i];
110+
destination[i] = c == (byte)'+' || c == (byte)'/' ? '_' : (char)c;
111+
}
112+
});
113+
}
114+
}

src/LogExtensions.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,4 +122,11 @@ internal static partial class LogExtensions
122122
Message = "Postgres backplane encountered error while handling OnInitialized event. Ignoring."
123123
)]
124124
public static partial void BackplaneErrorDuringOnInitialized(this ILogger logger, Exception ex);
125+
126+
[LoggerMessage(
127+
EventId = 160,
128+
Level = LogLevel.Error,
129+
Message = "Postgres backplane configuration is invalid: {Message}"
130+
)]
131+
public static partial void BackplaneConfigurationInvalid(this ILogger logger, string message);
125132
}

src/PostgresBackplaneOptions.cs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,24 @@
22

33
namespace PostgreSignalR;
44

5+
/// <summary>
6+
/// Postgres limits channel names to identifiers up to 63 characters.
7+
/// To guarantee this constraint is met, the backplane normalizes channel names.
8+
/// </summary>
9+
public enum ChannelNameNormaization
10+
{
11+
/// <summary>
12+
/// Only applies normalization to channel names greater than 63 characters.
13+
/// For large names, the last 8 characters will be a hash of the channel name
14+
/// </summary>
15+
Truncate,
16+
17+
/// <summary>
18+
/// Always hashes the channel name. The prefix is not modified.
19+
/// </summary>
20+
HashAlways
21+
}
22+
523
/// <summary>
624
/// Handler for <see cref="PostgresBackplaneOptions.OnInitialized"/>
725
/// </summary>
@@ -21,6 +39,17 @@ public class PostgresBackplaneOptions
2139
/// </remarks>
2240
public string Prefix { get; set; } = "postgresignalr";
2341

42+
/// <summary>
43+
/// Configures how the backplane normalizes channel names.
44+
/// Possible values:
45+
/// <list type="bullet">
46+
/// <item><see cref="ChannelNameNormaization.Truncate"/>: Truncates long channel names with a short hash. Better for performance.</item>
47+
/// <item><see cref="ChannelNameNormaization.HashAlways"/>: Always hashes channel names. Better for safety.</item>
48+
/// </list>
49+
/// Default: <see cref="ChannelNameNormaization.Truncate"/>
50+
/// </summary>
51+
public ChannelNameNormaization ChannelNameNormaization { get; set; } = ChannelNameNormaization.Truncate;
52+
2453
/// <summary>
2554
/// Configures the <see href="https://www.npgsql.org/doc/api/Npgsql.NpgsqlDataSource.html">NpgsqlDataSource</see> to connect to the Postgres database.
2655
/// </summary>
@@ -41,4 +70,16 @@ public class PostgresBackplaneOptions
4170

4271
internal void InvokeOnInitialized() =>
4372
OnInitialized?.Invoke();
73+
74+
internal bool IsValid(out string? message)
75+
{
76+
message = null;
77+
78+
if (Prefix.Length >= 20)
79+
{
80+
message = "Prefix must be less than 20 characters.";
81+
}
82+
83+
return message is null;
84+
}
4485
}

src/PostgresChannels.cs

Lines changed: 0 additions & 72 deletions
This file was deleted.

0 commit comments

Comments
 (0)