Skip to content
Open
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
12 changes: 12 additions & 0 deletions src/IceRpc/Internal/IceProtocolConnection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -689,6 +689,18 @@ private static (int RequestId, IceRequestHeader Header, PipeReader? ContextReade
var requestHeader = new IceRequestHeader(ref decoder);
requestHeader.Facet.CheckFacetCount();

// Reject operation names that contain characters outside the printable ASCII range. The path is built from
// requestHeader.Identity via IceIdentity.ToPath, which percent-escapes any unsafe characters, so it does not
// need an additional check here.
try
{
ServiceAddress.CheckOperation(requestHeader.Operation);
}
catch (FormatException exception)
{
throw new InvalidDataException(exception.Message, exception);
}

Pipe? contextPipe = null;
long pos = decoder.Consumed;
int count = decoder.DecodeSize();
Expand Down
14 changes: 14 additions & 0 deletions src/IceRpc/Internal/IceRpcProtocolConnection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1227,6 +1227,20 @@ async Task<OutgoingResponse> PerformDispatchRequestAsync(
{
var decoder = new SliceDecoder(buffer);
var header = new IceRpcRequestHeader(ref decoder);

// Reject paths and operation names that contain characters outside the printable ASCII range. This
// matches the constraints already enforced by ServiceAddress and by the Slice identifier grammar, and
// prevents control characters (notably CR/LF/NUL) from flowing into loggers and other downstream sinks.
try
{
ServiceAddress.CheckPath(header.Path);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I'm not sure that IceRPC has the same restriction as Ice here. i.e. ascii only characters.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

For Path we already have this check in ServiceAddress, so it makes sense we don't accept paths that are not valid according to the ServiceAddress rules.

For Operation I guess we didn't really specify what a valid operation names is in the "icerpc" protocol, at the protocol level is just a string. And this should probably be handled in the integration layer if at all, for example Slice operations are derived from Slice identifiers and have the same limitations that the identifier has. Same for Protobuf, they are derived from the Protobuf IDL and have its own rules.

I think is probably wrong to check the operation name here, as the protocol layer doesn't specify what the operation name is, checking the path seems correct because ServiceAddress format is specified in the protocol.

Copy link
Copy Markdown
Member

@externl externl Apr 29, 2026

Choose a reason for hiding this comment

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

Ok, I couldn't find anywhere in the docs were we way we only accept ASCII chars (but maybe its there?).

Is this check really required here? ServiceAddress' Path init function already does this check, no?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Is this check really required here? ServiceAddress' Path init function already does this check, no?

ServiceAddress is not used here, we decode the Path as a string, using IceRpcRequestHeader

[cs::readonly]
compact struct IceRpcRequestHeader {
path: string
operation: string
// fields: Dictionary<RequestFieldKey, Sequence<uint8>> (encoded/decoded manually)
}

Then the path string is assigned to IncomingRequest.Path, another string without further validation.

public string Path { get; init; } = "/";

Maybe is clear to move the validation to IncomingRequest.Path init

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Right. Yeah it would make sense to also have it there.

Reminds me of https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-validate

ServiceAddress.CheckOperation(header.Operation);
}
catch (FormatException exception)
{
throw new InvalidDataException(exception.Message, exception);
}

(IDictionary<RequestFieldKey, ReadOnlySequence<byte>> fields, PipeReader? pipeReader) =
DecodeFieldDictionary(
ref decoder,
Expand Down
19 changes: 18 additions & 1 deletion src/IceRpc/ServiceAddress.cs
Original file line number Diff line number Diff line change
Expand Up @@ -490,7 +490,7 @@ internal static void CheckPath(string path)
if (path.Length == 0 || path[0] != '/' || !IsValid(path, _notValidInPath))
{
throw new FormatException(
$"Invalid path '{path}'; a valid path starts with '/' and contains only unreserved characters, '%', and reserved characters other than '?' and '#'.");
"Invalid path; a valid path starts with '/' and contains only unreserved characters, '%', and reserved characters other than '?' and '#'.");
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think printing the 'invalid path' (like we did before) would be a valuable piece of information for debugging.

I recognize there's a tradeoff where: if there's control characters, the message might look weird. but I think the benefit of seeing "oh, 𒀗𒀝/𒀱 was the bad path" outweighs the very unlikely situation where you've gotten control or formatting characters in here.

}
}

Expand All @@ -501,6 +501,23 @@ internal static void CheckPath(string path)
/// <see langword="false" />.</returns>
internal static bool IsValidParamValue(string value) => IsValid(value, _notValidInParamValue);

/// <summary>Checks if <paramref name="operation" /> is a valid operation name. A valid operation name contains
/// only printable ASCII characters (in the inclusive range <c>0x21</c> to <c>0x7E</c>) and may be empty.
/// </summary>
/// <param name="operation">The operation name to check.</param>
/// <exception cref="FormatException">Thrown if the operation name is not valid.</exception>
/// <remarks>This range covers every legal Slice identifier and rejects all C0 control characters as well as DEL
/// and any non-ASCII content. Empty operation names are accepted because <see cref="OutgoingRequest.Operation" />
/// defaults to the empty string.</remarks>
internal static void CheckOperation(string operation)
{
if (operation.AsSpan().IndexOfAnyExceptInRange(FirstValidChar, LastValidChar) != -1)
{
throw new FormatException(
"Invalid operation name; an operation name contains only printable ASCII characters.");
}
}

/// <summary>"unchecked" constructor used by the Ice decoder when decoding a service address.
/// </summary>
internal ServiceAddress(
Expand Down
43 changes: 43 additions & 0 deletions tests/IceRpc.Tests/IceProtocolConnectionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,49 @@ public async Task Thrown_dispatcher_exception_is_classified(string exceptionKind
Assert.That(response.StatusCode, Is.EqualTo(expectedStatusCode));
}

/// <summary>Verifies that a request whose operation name contains characters outside the printable ASCII range
/// is rejected at decode time so peer-controlled control characters cannot reach loggers and other downstream
/// sinks. The dispatcher must not be invoked, and because ice cannot recover from a decode failure mid-stream,
/// the connection aborts and the pending invocation fails with
/// <see cref="IceRpcError.ConnectionAborted" />.</summary>
[TestCase("op\r\nINJECTED")] // CR/LF in operation
[TestCase("op\0")] // NUL in operation
public async Task Request_with_invalid_operation_aborts_server_connection(string operation)
{
// Arrange
var dispatcher = new InlineDispatcher((request, cancellationToken) =>
{
Assert.Fail("The dispatcher must not be invoked for a request with an invalid operation name.");
return new(new OutgoingResponse(request));
});

await using ServiceProvider provider = new ServiceCollection()
.AddProtocolTest(Protocol.Ice, dispatcher)
.BuildServiceProvider(validateScopes: true);

ClientServerProtocolConnection sut = provider.GetRequiredService<ClientServerProtocolConnection>();
(_, Task serverShutdownRequested) = await sut.ConnectAsync();

using var request = new OutgoingRequest(new ServiceAddress(Protocol.Ice) { Path = "/foo" })
{
Operation = operation
};
Task<IncomingResponse> invokeTask = sut.Client.InvokeAsync(request);

// Wait until the server's read loop has rejected the invalid header and signaled shutdown, so the
// disposal below cannot race with request decoding.
await serverShutdownRequested;

// Act: closing the server surfaces the abort to the client's pending invocation. ReadFailed signals
// shutdown but doesn't close the duplex connection on its own.
await sut.Server.DisposeAsync();

// Assert
Assert.That(
async () => await invokeTask,
Throws.InstanceOf<IceRpcException>().With.Property("IceRpcError").EqualTo(IceRpcError.ConnectionAborted));
}

/// <summary>Verifies that a StatusCode dispatched by the server is encoded as a ReplyStatus and decoded back to
/// the expected StatusCode by the client.</summary>
[Test, TestCaseSource(nameof(StatusCodeRoundTripSource))]
Expand Down
48 changes: 48 additions & 0 deletions tests/IceRpc.Tests/IceRpcProtocolConnectionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,54 @@ public async Task Invalid_request_refused(byte[] invalidRequestBytes, bool succe
Throws.Nothing);
}

/// <summary>Verifies that a request whose path or operation name contains characters outside the printable
/// ASCII range is rejected at decode time, so peer-controlled control characters cannot reach loggers and
/// other downstream sinks.</summary>
[TestCase("/foo\r\nINJECTED", "op")] // CR/LF in path
[TestCase("/foo", "op\r\nINJECTED")] // CR/LF in operation
[TestCase("/foo\0", "op")] // NUL in path
[TestCase("/foo", "op\0")] // NUL in operation
[TestCase("foo", "op")] // path without leading '/'
public async Task Request_with_invalid_path_or_operation_is_rejected(string path, string operation)
{
// Arrange
var taskExceptionObserver = new TestTaskExceptionObserver();

await using ServiceProvider provider = new ServiceCollection()
.AddProtocolTest(Protocol.IceRpc)
.AddTestMultiplexedTransportDecorator()
.AddSingleton<ITaskExceptionObserver>(taskExceptionObserver)
.BuildServiceProvider(validateScopes: true);

ClientServerProtocolConnection sut = provider.GetRequiredService<ClientServerProtocolConnection>();
await sut.ConnectAsync();

var clientTransport = provider.GetRequiredService<TestMultiplexedClientTransportDecorator>();
IMultiplexedConnection clientTransportConnection = clientTransport.LastCreatedConnection;
IMultiplexedStream stream = await clientTransportConnection.CreateStreamAsync(bidirectional: true, default);

var writer = new MemoryBufferWriter(new byte[256]);
var encoder = new SliceEncoder(writer);
Span<byte> sizePlaceholder = encoder.GetPlaceholderSpan(4);
int startPos = encoder.EncodedByteCount;
encoder.EncodeString(path);
encoder.EncodeString(operation);
encoder.EncodeVarUInt62(0); // empty field dictionary
SliceEncoder.EncodeVarUInt62((ulong)(encoder.EncodedByteCount - startPos), sizePlaceholder);

// Act
stream.Output.Write(writer.WrittenMemory.Span);
await stream.Output.FlushAsync();
stream.Output.CompleteOutput(success: true);

// Assert
Assert.That(
async () => await taskExceptionObserver.DispatchRefusedException,
Is.InstanceOf<IceRpcException>()
.With.Property("IceRpcError").EqualTo(IceRpcError.IceRpcError)
.And.InnerException.InstanceOf<InvalidDataException>());
}

/// <summary>Verifies that a request with a field count large enough that count * 2 would overflow int arithmetic
/// is correctly rejected.</summary>
[Test]
Expand Down
Loading