From a507ed2a009ea9cc7fe5b7e78c678a4a88d8cdc4 Mon Sep 17 00:00:00 2001 From: Kayos Date: Tue, 28 Apr 2026 23:22:48 -0700 Subject: [PATCH] =?UTF-8?q?clients/csharp:=20apply=20audit=20findings=20?= =?UTF-8?q?=E2=80=94=20JSON=20depth=20caps=20+=20stream=20lifecycle=20(09a?= =?UTF-8?q?ca58=20=E2=86=92=20new)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MEDIUM: - M1: JsonSerializerOptions.MaxDepth = 32 on the consolidated JsonDefaults.Options (referenced from both ForgeClient and RunResult.AsJson) so the result payload's arbitrary upstream JSON cannot stack-walk the runtime. - M2: JsonDocumentOptions.MaxDepth = 32 in SummarizeBody for parsing error-body summaries — defensive belt alongside the existing 8 MiB body cap. - M3: UploadStreamAsync doc updated to match reality — the input stream IS disposed when the request completes (matches HttpClient / MultipartFormDataContent / StreamContent convention). Old doc was incorrect; chose doc-update over a non-disposing wrapper to stay closest to standard .NET stream semantics. LOW: - L2: RunResult.AsJson() now guards JsonValueKind.Undefined and returns default(T) instead of throwing InvalidOperationException (e.g. when RunResult is constructed without a server payload). - L4: IsNullOrWhiteSpace consistent across RunRequest.Prompt, CreateTokenRequest.Name, RevokeTokenAsync.name, UploadFileAsync.path, UploadStreamAsync.fileName (was IsNullOrEmpty letting space through). Nit polish: - BaseUrl cached in ctor instead of rebuilt per access. - JsonDefaults moved to its own file (Models/JsonDefaults.cs) and is now the single source of truth for serializer options across the client. - examples/Basic/Program.cs comment fixed: '60s' → '120s' to match TimeSpan.FromSeconds(120). README: - HTTPS / WireGuard recommendation in the Notes section — SDK does not enforce HTTPS, callers off-LAN should tunnel. - .NET 8.0.10+ runtime recommendation with cref to CVE-2024-30105 and CVE-2024-43485 (SDK does not exercise the affected code paths; belt-and-suspenders). - UploadStream section reflects the corrected disposal contract. Tests (12 → 19, all passing): - JsonOpts_MaxDepth_RejectsDeeplyNested — 200-deep result rejected via ForgeTransportException wrapping JsonException, no stack overflow. - SummarizeBody_DeeplyNestedHandled — 200-deep error body still produces ForgeAuthException with raw body intact; summary parse fails closed without crashing. - UploadStreamAsync_DisposesCallerStream — DisposeObservingStream helper verifies the contract change. - AsJson_OnUndefinedResult_DefaultReturned — reference + value type. - RunRequest_PromptWithOnlyWhitespace_Rejected. - CreateToken_NameWithOnlyWhitespace_Rejected. - BaseUrl_Cached_ReusesString — Assert.Same identity check. Build: dotnet build -c Release -m:1 clean (0 warnings, 0 errors). Tests: dotnet test -c Release -m:1 → 19 passed, 0 failed. Pack: dotnet pack -c Release -o dist -m:1 clean. Vulns: dotnet list package --vulnerable --include-transitive → 0. Audit: memory/clawdforge-audits/csharp-09aca58.md --- clients/csharp/README.md | 21 ++- clients/csharp/examples/Basic/Program.cs | 2 +- clients/csharp/src/Clawdforge/ForgeClient.cs | 44 +++-- .../src/Clawdforge/Models/JsonDefaults.cs | 30 ++++ .../csharp/src/Clawdforge/Models/RunResult.cs | 19 ++- .../Clawdforge.Tests/ForgeClientTests.cs | 160 ++++++++++++++++++ 6 files changed, 253 insertions(+), 23 deletions(-) create mode 100644 clients/csharp/src/Clawdforge/Models/JsonDefaults.cs diff --git a/clients/csharp/README.md b/clients/csharp/README.md index 4526d49..9201c2d 100644 --- a/clients/csharp/README.md +++ b/clients/csharp/README.md @@ -163,7 +163,12 @@ await client.RunAsync(new RunRequest { ### `UploadStreamAsync(Stream, string fileName, int ttlSecs = 0, CancellationToken)` Same as `UploadFileAsync` but takes any `Stream`, useful for in-memory -blobs or piped data. The stream is not closed by the SDK. +blobs or piped data. The stream **is disposed** by the SDK once the +request completes — this matches the standard `HttpClient` / +`MultipartFormDataContent` / `StreamContent` chain (they all call +`Dispose()` through to the underlying stream). If the caller needs to +retain ownership of the stream, wrap it in a non-disposing adapter +before passing it in. ### Admin (require admin bootstrap token) @@ -282,3 +287,17 @@ dotnet run --project examples/Basic if present — the server ignores it. - File uploads stream via `StreamContent(FileStream)` — the file body is not buffered into memory. +- **Transport security:** the SDK does not enforce HTTPS — `BaseUrl` may + be plain `http://` (clawdforge is a LAN-only service in our + deployment). For any non-localhost / non-LAN target, use HTTPS or + tunnel the connection through WireGuard / a VPN. Bearer tokens travel + in `Authorization` headers; cleartext over an untrusted hop is a token + leak. +- **Runtime:** .NET `8.0.10` or later is recommended. Two BCL CVEs + ([CVE-2024-30105][cve1], [CVE-2024-43485][cve2]) cover JSON DoS paths + the SDK does not currently exercise (`DeserializeAsyncEnumerable` and + `JsonExtensionData`), but pinning to a patched runtime is cheap + belt-and-suspenders insurance. + +[cve1]: https://github.com/dotnet/announcements/issues/322 +[cve2]: https://github.com/dotnet/announcements/issues/325 diff --git a/clients/csharp/examples/Basic/Program.cs b/clients/csharp/examples/Basic/Program.cs index f2dfd4d..b576d6c 100644 --- a/clients/csharp/examples/Basic/Program.cs +++ b/clients/csharp/examples/Basic/Program.cs @@ -20,7 +20,7 @@ using var client = new ForgeClient(new ForgeOptions Token = token, }); -// 60s ceiling for the whole demo via cancellation. +// 120s ceiling for the whole demo via cancellation. using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(120)); var ct = cts.Token; diff --git a/clients/csharp/src/Clawdforge/ForgeClient.cs b/clients/csharp/src/Clawdforge/ForgeClient.cs index a2c4971..ff6ca28 100644 --- a/clients/csharp/src/Clawdforge/ForgeClient.cs +++ b/clients/csharp/src/Clawdforge/ForgeClient.cs @@ -2,7 +2,6 @@ using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.Json; -using System.Text.Json.Serialization; using Clawdforge.Exceptions; using Clawdforge.Models; @@ -28,19 +27,25 @@ namespace Clawdforge; /// public sealed class ForgeClient : IDisposable { - private static readonly JsonSerializerOptions JsonOpts = new() + // Shared default JSON options live in Models/JsonDefaults.cs to avoid + // duplicate config drift between the wire client and RunResult helpers. + private static readonly JsonSerializerOptions JsonOpts = JsonDefaults.Options; + + // Defensive cap on JSON-document parsing depth used by the error-body + // summarizer. Belt-and-suspenders alongside the 8 MiB body cap. + private static readonly JsonDocumentOptions ErrorBodyDocOpts = new() { - PropertyNameCaseInsensitive = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + MaxDepth = 32, }; private readonly HttpClient _http; private readonly bool _ownsHttpClient; private readonly Uri _baseUri; private readonly string? _token; + private readonly string _baseUrlString; /// The base URL the client targets (no trailing slash). - public string BaseUrl => _baseUri.GetLeftPart(UriPartial.Authority) + _baseUri.AbsolutePath.TrimEnd('/'); + public string BaseUrl => _baseUrlString; /// /// Construct a standalone backed by an @@ -68,6 +73,7 @@ public sealed class ForgeClient : IDisposable } _baseUri = new Uri(options.BaseUrl.TrimEnd('/') + "/", UriKind.Absolute); + _baseUrlString = _baseUri.GetLeftPart(UriPartial.Authority) + _baseUri.AbsolutePath.TrimEnd('/'); _token = options.Token; if (httpClient is null) @@ -107,7 +113,7 @@ public sealed class ForgeClient : IDisposable public async Task RunAsync(RunRequest request, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request); - if (string.IsNullOrEmpty(request.Prompt)) + if (string.IsNullOrWhiteSpace(request.Prompt)) { throw new ArgumentException("RunRequest.Prompt is required", nameof(request)); } @@ -133,7 +139,7 @@ public sealed class ForgeClient : IDisposable int ttlSecs = 0, CancellationToken cancellationToken = default) { - if (string.IsNullOrEmpty(path)) + if (string.IsNullOrWhiteSpace(path)) { throw new ArgumentException("path is required", nameof(path)); } @@ -155,7 +161,16 @@ public sealed class ForgeClient : IDisposable /// in-memory blobs or data piped from another source. The stream is read /// without buffering its contents into the SDK. /// - /// Stream of file bytes; not closed by the SDK. + /// + /// Stream of file bytes. The stream IS disposed by the SDK when the + /// request completes (this matches the standard + /// / + /// / + /// contract — they all chain + /// through to the underlying stream). + /// If the caller needs to retain ownership of , + /// wrap it in a non-disposing adapter before passing it in. + /// /// Filename to advertise on the wire. /// /// Server clamps to 60..86400. Pass 0 to use the server @@ -169,7 +184,7 @@ public sealed class ForgeClient : IDisposable CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(content); - if (string.IsNullOrEmpty(fileName)) + if (string.IsNullOrWhiteSpace(fileName)) { throw new ArgumentException("fileName is required", nameof(fileName)); } @@ -201,7 +216,7 @@ public sealed class ForgeClient : IDisposable CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request); - if (string.IsNullOrEmpty(request.Name)) + if (string.IsNullOrWhiteSpace(request.Name)) { throw new ArgumentException("CreateTokenRequest.Name is required", nameof(request)); } @@ -228,7 +243,7 @@ public sealed class ForgeClient : IDisposable /// public async Task RevokeTokenAsync(string name, CancellationToken cancellationToken = default) { - if (string.IsNullOrEmpty(name)) + if (string.IsNullOrWhiteSpace(name)) { throw new ArgumentException("name is required", nameof(name)); } @@ -353,7 +368,10 @@ public sealed class ForgeClient : IDisposable if (string.IsNullOrWhiteSpace(body)) return null; try { - using var doc = JsonDocument.Parse(body); + // Cap parse depth to keep a hostile error body from being a DoS + // vector via JSON nesting. 32 is well above any real API shape + // we ever expect from clawdforge. + using var doc = JsonDocument.Parse(body, ErrorBodyDocOpts); if (doc.RootElement.ValueKind == JsonValueKind.Object) { foreach (var key in new[] { "error", "detail", "message" }) @@ -366,7 +384,7 @@ public sealed class ForgeClient : IDisposable } } } - catch (JsonException) { /* not JSON */ } + catch (JsonException) { /* not JSON / too deep */ } return null; } diff --git a/clients/csharp/src/Clawdforge/Models/JsonDefaults.cs b/clients/csharp/src/Clawdforge/Models/JsonDefaults.cs new file mode 100644 index 0000000..75d760d --- /dev/null +++ b/clients/csharp/src/Clawdforge/Models/JsonDefaults.cs @@ -0,0 +1,30 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Clawdforge.Models; + +/// +/// Shared used by the wire client and +/// the helpers. +/// +/// +/// +/// Centralized so that ForgeClient.JsonOpts and +/// RunResult.AsJson<T>() can't drift. Keep all defaults here. +/// +/// +/// MaxDepth = 32 caps deserialization recursion. The default is 64 +/// and the result payload is arbitrary upstream JSON, so we trim +/// the ceiling defensively to keep a hostile or malformed reply from +/// stack-walking the runtime. +/// +/// +internal static class JsonDefaults +{ + internal static readonly JsonSerializerOptions Options = new() + { + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + MaxDepth = 32, + }; +} diff --git a/clients/csharp/src/Clawdforge/Models/RunResult.cs b/clients/csharp/src/Clawdforge/Models/RunResult.cs index ce8c60f..f88c451 100644 --- a/clients/csharp/src/Clawdforge/Models/RunResult.cs +++ b/clients/csharp/src/Clawdforge/Models/RunResult.cs @@ -46,12 +46,23 @@ public sealed class RunResult /// Deserialize as using the /// supplied options (or a sensible default). /// + /// + /// Returns default when 's + /// is + /// (e.g. RunResult was + /// constructed without a server payload). For all other kinds the call + /// is forwarded to JsonElement.Deserialize<T>. + /// /// /// Thrown if doesn't deserialize into /// . /// public T? AsJson(JsonSerializerOptions? options = null) { + if (Result.ValueKind == JsonValueKind.Undefined) + { + return default; + } return Result.Deserialize(options ?? JsonDefaults.Options); } @@ -72,11 +83,3 @@ public sealed class RunResult } } -internal static class JsonDefaults -{ - internal static readonly JsonSerializerOptions Options = new() - { - PropertyNameCaseInsensitive = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - }; -} diff --git a/clients/csharp/tests/Clawdforge.Tests/ForgeClientTests.cs b/clients/csharp/tests/Clawdforge.Tests/ForgeClientTests.cs index 0ee19c0..c69a468 100644 --- a/clients/csharp/tests/Clawdforge.Tests/ForgeClientTests.cs +++ b/clients/csharp/tests/Clawdforge.Tests/ForgeClientTests.cs @@ -338,6 +338,29 @@ public class ForgeClientTests client.RunAsync(new RunRequest { Prompt = "" })); } + [Fact] + public async Task RunRequest_PromptWithOnlyWhitespace_Rejected() + { + // Audit L4: required-string args use IsNullOrWhiteSpace consistently — + // a prompt of " \t\n " is not a meaningful request. + using var client = new ForgeClient( + new ForgeOptions { BaseUrl = "http://forge.test", Token = "cf_test" }); + + await Assert.ThrowsAsync(() => + client.RunAsync(new RunRequest { Prompt = " \t\n " })); + } + + [Fact] + public async Task CreateToken_NameWithOnlyWhitespace_Rejected() + { + // Audit L4: required-string args use IsNullOrWhiteSpace consistently. + using var client = new ForgeClient( + new ForgeOptions { BaseUrl = "http://forge.test", Token = "admin-token" }); + + await Assert.ThrowsAsync(() => + client.CreateTokenAsync(new CreateTokenRequest { Name = " " })); + } + [Fact] public void BaseUrl_IsTrimmedAtConstruction() { @@ -347,6 +370,111 @@ public class ForgeClientTests Assert.Equal("http://forge.test", client.BaseUrl); } + [Fact] + public void BaseUrl_Cached_ReusesString() + { + // Audit nit: BaseUrl getter shouldn't rebuild the string on every + // access. Identity check confirms a single cached instance. + using var client = new ForgeClient( + new ForgeOptions { BaseUrl = "http://forge.test/", Token = "cf_test" }); + + var first = client.BaseUrl; + var second = client.BaseUrl; + Assert.Same(first, second); + } + + [Fact] + public async Task JsonOpts_MaxDepth_RejectsDeeplyNested() + { + // Audit M1: ForgeClient JsonOpts must cap deserialization depth so + // the server's `result` field can't stack-walk the runtime via + // pathological nesting. We synthesize a 200-deep object and verify + // the SDK surfaces a transport exception (JsonException-wrapped), + // NOT a stack-overflow / process termination. + var deepResult = new string('[', 200) + new string(']', 200); + var body = "{\"ok\":true,\"result\":" + deepResult + + ",\"duration_ms\":1,\"stop_reason\":\"end_turn\"}"; + + var handler = new MockHandler((req, ct) => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(body, Encoding.UTF8, "application/json"), + }); + + using var http = new HttpClient(handler); + using var client = new ForgeClient( + new ForgeOptions { BaseUrl = "http://forge.test", Token = "cf_test" }, + http); + + var ex = await Assert.ThrowsAsync(() => + client.RunAsync(new RunRequest { Prompt = "x" })); + Assert.IsType(ex.InnerException); + } + + [Fact] + public async Task SummarizeBody_DeeplyNestedHandled() + { + // Audit M2: SummarizeBody parses the error body to extract a + // `detail`/`error`/`message` string. A hostile error body with + // pathological nesting must not crash the summarize path — the + // JsonDocumentOptions.MaxDepth=32 cap means JsonDocument.Parse + // throws JsonException, which SummarizeBody swallows. Verify the + // 401 still surfaces as ForgeAuthException with body intact. + var deeplyNestedBody = "{\"detail\":" + new string('[', 200) + new string(']', 200) + "}"; + + var handler = new MockHandler((req, ct) => new HttpResponseMessage(HttpStatusCode.Unauthorized) + { + Content = new StringContent(deeplyNestedBody, Encoding.UTF8, "application/json"), + }); + + using var http = new HttpClient(handler); + using var client = new ForgeClient( + new ForgeOptions { BaseUrl = "http://forge.test", Token = "cf_test" }, + http); + + var ex = await Assert.ThrowsAsync(() => + client.RunAsync(new RunRequest { Prompt = "x" })); + Assert.Equal(401, ex.StatusCode); + // Raw body is preserved verbatim; only the summary parse is gated. + Assert.Contains("detail", ex.Body); + } + + [Fact] + public async Task UploadStreamAsync_DisposesCallerStream() + { + // Audit M3: documented contract is that the caller's stream IS + // disposed by the SDK once the request completes (matches + // HttpClient/MultipartFormDataContent/StreamContent convention). + var handler = new MockHandler((req, ct) => + JsonResponse(new { file_token = "ff_x", ttl_secs = 3600, size = 5 })); + + using var http = new HttpClient(handler); + using var client = new ForgeClient( + new ForgeOptions { BaseUrl = "http://forge.test", Token = "cf_test" }, + http); + + var observed = new DisposeObservingStream(new MemoryStream(Encoding.UTF8.GetBytes("hello"))); + var ft = await client.UploadStreamAsync(observed, "hello.txt", ttlSecs: 3600); + + Assert.Equal("ff_x", ft.Token); + Assert.True(observed.WasDisposed, + "UploadStreamAsync should dispose the caller's stream — see README contract."); + } + + [Fact] + public void AsJson_OnUndefinedResult_DefaultReturned() + { + // Audit L2: RunResult.AsJson() previously threw InvalidOperationException + // when Result.ValueKind == Undefined (default JsonElement). Guarded + // to return default(T) instead. + var rr = new RunResult { Ok = true, DurationMs = 0 }; + Assert.Equal(JsonValueKind.Undefined, rr.Result.ValueKind); + + // Reference type: returns null. + Assert.Null(rr.AsJson()); + // Value type: returns default(int) = 0. + Assert.Equal(0, rr.AsJson()); + } + // ---- helpers ----------------------------------------------------------- private static HttpResponseMessage JsonResponse(object payload, HttpStatusCode status = HttpStatusCode.OK) @@ -365,6 +493,38 @@ public class ForgeClientTests public string Food { get; init; } = string.Empty; } + /// + /// Wraps an inner stream and flips a flag the first time + /// runs. Used by the + /// UploadStreamAsync disposal-contract test. + /// + private sealed class DisposeObservingStream : Stream + { + private readonly Stream _inner; + public bool WasDisposed { get; private set; } + + public DisposeObservingStream(Stream inner) { _inner = inner; } + + public override bool CanRead => _inner.CanRead; + public override bool CanSeek => _inner.CanSeek; + public override bool CanWrite => _inner.CanWrite; + public override long Length => _inner.Length; + public override long Position { get => _inner.Position; set => _inner.Position = value; } + + public override void Flush() => _inner.Flush(); + public override int Read(byte[] buffer, int offset, int count) => _inner.Read(buffer, offset, count); + public override long Seek(long offset, SeekOrigin origin) => _inner.Seek(offset, origin); + public override void SetLength(long value) => _inner.SetLength(value); + public override void Write(byte[] buffer, int offset, int count) => _inner.Write(buffer, offset, count); + + protected override void Dispose(bool disposing) + { + WasDisposed = true; + if (disposing) _inner.Dispose(); + base.Dispose(disposing); + } + } + private sealed class MockHandler : HttpMessageHandler { private readonly Func> _handler;