diff --git a/CHANGELOG.md b/CHANGELOG.md index 7738292..7dd3e24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,21 @@ All notable changes to IntentMesh. Claims are test-backed; see [docs/MATURITY.md](docs/MATURITY.md) for the production-ready / experimental / future breakdown. +## v1.10.1 — Authz hardening (traversal-safe ids, body-cap) + +A follow-up security pass on the v1.10.0 authz surface. **232 passing + 3 env-gated skipped.** + +- **Traversal-safe tenant/principal ids** — `AuthIds.IsValid` now rejects ids that start with `.` or `-` + or contain no alphanumeric, so `.`, `..`, dotfiles, and option-like names are refused even though their + characters are in the allowed set. Closes a path-traversal / tenant-isolation-bypass risk where a + `..`-shaped tenant could resolve outside the per-tenant runs root. +- **Tenant-path containment** — the per-tenant store factory re-validates the tenant id and verifies the + resolved directory stays under the runs root before constructing the store (defense-in-depth). +- **Request-body cap holds for chunked bodies** — the `/api` size guard no longer relies only on a + declared `Content-Length`; it also sets the Kestrel max-request-body limit so a chunked or + missing-length body is rejected while binding (an omitted/spoofed `Content-Length` no longer bypasses + the 256 KB cap). + ## v1.10.0 — Real multi-tenant authorization Builds out the authz boundary that the fourth review flagged as a not-yet-built future seam — it is now diff --git a/Directory.Build.props b/Directory.Build.props index b54653b..3397faa 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -5,7 +5,7 @@ Demos, tools, the web host, the E2E/bench runners, the template, and tests stay non-packable. --> - 1.10.0 + 1.10.1 Chad Sandor wyckit IntentMesh @@ -18,7 +18,7 @@ MIT false - v1.10.0 — real multi-tenant authorization (principal/tenant/role, tenant-isolated runs, server-issued approval challenges). See CHANGELOG.md. + v1.10.1 — authz hardening: reject traversal-shaped tenant/principal ids, tenant-path containment, and a request-body cap that holds for chunked bodies. See CHANGELOG.md. true diff --git a/README.md b/README.md index 3b3ad45..8c120ab 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,7 @@ dotnet run --project src/IntentMesh.Cli -- --trace "plan my Friday and draft Sar dotnet run --project src/IntentMesh.Web # then open the printed localhost URL # tests -dotnet test IntentMesh.slnx # 218 passing (+3 env-gated skipped) +dotnet test IntentMesh.slnx # 232 passing (+3 env-gated skipped) ``` ### Wrap your own agent (the SDK on-ramp) @@ -190,7 +190,7 @@ v1.7 platform:** Phase 1 (clarity) ✓ · Phase 2 (signed artifacts, replay, con Phase 3 (Control Room v1) ✓ · Phase 4 (IntentBench 25/25) ✓ · Phase 5 (SDK + MCP proxy / OpenAPI import / real-adapter example) ✓ · Phase 6 (manifesto, whitepaper, landing) ✓. **v1.7** adds the adoptable platform surface (full-lifecycle SDK + host template, real-LLM-proposer hardening, -operator workflow, audit operations). **218 passing (+3 env-gated skipped) tests · IntentBench 25/25 · TLM 7/7.** +operator workflow, audit operations). **232 passing (+3 env-gated skipped) tests · IntentBench 25/25 · TLM 7/7.** **Proven vs. experimental vs. future (claims discipline).** [docs/MATURITY.md](docs/MATURITY.md) is the canonical statement: every *proven* claim has a passing test that would fail if it stopped being @@ -207,7 +207,7 @@ and the [CHANGELOG](CHANGELOG.md). ## Status Research prototype with a production-shaped core, **v1.8.0**. Symbolic layer: 7 TLMs, ~125 concepts, -7/7 round-trip verify; typed action contracts across four domains. **xUnit 218 passing (+3 env-gated skipped).** Five demo +7/7 round-trip verify; typed action contracts across four domains. **xUnit 232 passing (+3 env-gated skipped).** Five demo scenarios. See [docs/MATURITY.md](docs/MATURITY.md) for the proven / experimental / future breakdown. Delivered beyond v0.1: diff --git a/docs/MATURITY.md b/docs/MATURITY.md index 50b442b..6fa0647 100644 --- a/docs/MATURITY.md +++ b/docs/MATURITY.md @@ -2,7 +2,7 @@ The single source of truth for **what is production-ready, what is experimental, and what is future work.** Every "proven" claim below is backed by a test that would fail if the claim stopped being -true (`dotnet test IntentMesh.slnx` — **218 passing, 3 env-gated skipped**). Nothing here is aspirational unless it says so. +true (`dotnet test IntentMesh.slnx` — **232 passing, 3 env-gated skipped**). Nothing here is aspirational unless it says so. > IntentMesh is a **research prototype with a production-shaped core**: the security kernel and its > guarantees are proven and stable; the *operational backends* around it (KMS, DB persistence, diff --git a/src/IntentMesh.Core/Auth.cs b/src/IntentMesh.Core/Auth.cs index 061a03c..06da50d 100644 --- a/src/IntentMesh.Core/Auth.cs +++ b/src/IntentMesh.Core/Auth.cs @@ -33,11 +33,16 @@ public sealed record AuthPrincipal(string PrincipalId, string TenantId, IReadOnl } /// Validation for principal/tenant ids. They become a path segment (per-tenant run root), so -/// they are restricted to a safe, traversal-free alphabet — a tenant id can never escape the runs root. +/// they are restricted to a safe, traversal-free shape — a tenant id can never escape the runs root. +/// Beyond the alphabet, an id must contain a letter or digit and may not start with '.' or '-', which +/// rejects "." / ".." / dotfiles / option-like names (e.g. "-rf") even though their characters are in +/// the allowed set. public static class AuthIds { public static bool IsValid(string? id) => !string.IsNullOrEmpty(id) && id.Length <= 64 + && id[0] != '.' && id[0] != '-' + && id.Any(char.IsAsciiLetterOrDigit) && id.All(c => char.IsAsciiLetterOrDigit(c) || c is '.' or '_' or '-'); } diff --git a/src/IntentMesh.Web/Program.cs b/src/IntentMesh.Web/Program.cs index 1d32cd1..f75b470 100644 --- a/src/IntentMesh.Web/Program.cs +++ b/src/IntentMesh.Web/Program.cs @@ -2,6 +2,7 @@ using System.Security.Cryptography; using System.Text; using IntentMesh.Core; +using Microsoft.AspNetCore.Http.Features; // IntentMesh Control Room — ASP.NET minimal API over IntentMesh.Core. Serves a dependency-free // SPA (wwwroot) and runs the pipeline on demand. No CDN, no npm — robust offline. @@ -115,10 +116,16 @@ if (context.Request.ContentLength > MaxApiBodyBytes) { - context.Response.StatusCode = 413; // Payload Too Large + context.Response.StatusCode = 413; // Payload Too Large — fast reject when Content-Length is declared await context.Response.WriteAsJsonAsync(new { error = $"request body exceeds {MaxApiBodyBytes} bytes" }); return; } + // A declared Content-Length can be omitted or wrong (e.g. chunked transfer-encoding), so also cap the + // actual bytes read: Kestrel enforces this limit while binding the body and rejects an over-limit + // request regardless of the header. (Null/read-only on some test servers — handled gracefully.) + var sizeLimit = context.Features.Get(); + if (sizeLimit is { IsReadOnly: false }) + sizeLimit.MaxRequestBodySize = MaxApiBodyBytes; if (path.StartsWithSegments("/api/auth/token")) { await next(); return; } // login endpoint is open @@ -406,7 +413,20 @@ static bool ConstTimeEq(string? a, string b) && CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(a), Encoding.UTF8.GetBytes(b)); // Per-tenant run store: each tenant's runs live under {runsDir}/t/{tenant} — isolation by construction. -FileRunArtifactStore StoreFor(string tenant) => new(Path.Combine(runsDir, "t", tenant)); +// Defense-in-depth: the tenant id is already validated when the principal is resolved, but re-validate +// here and confirm the resolved directory stays under the runs root before constructing the store, so a +// traversal-shaped tenant can never escape (no store is created for an out-of-bounds path). +FileRunArtifactStore StoreFor(string tenant) +{ + if (!AuthIds.IsValid(tenant)) + throw new ArgumentException($"invalid tenant id '{tenant}'", nameof(tenant)); + var rootFull = Path.TrimEndingDirectorySeparator(Path.GetFullPath(runsDir)); + var dir = Path.GetFullPath(Path.Combine(rootFull, "t", tenant)); + var cmp = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + if (!dir.StartsWith(rootFull + Path.DirectorySeparatorChar, cmp)) + throw new ArgumentException($"tenant '{tenant}' resolves outside the runs root", nameof(tenant)); + return new FileRunArtifactStore(dir); +} // Approvals: gated node ids are only ever applied when attested by a server-issued challenge. record RunRequest(string? Prompt, string[]? Approvals); diff --git a/tests/IntentMesh.Tests/AuthTests.cs b/tests/IntentMesh.Tests/AuthTests.cs index e9fa1e3..3ae4aa8 100644 --- a/tests/IntentMesh.Tests/AuthTests.cs +++ b/tests/IntentMesh.Tests/AuthTests.cs @@ -136,5 +136,25 @@ public void Trusted_proxy_headers_map_to_a_principal_and_reject_invalid_ids() Assert.False(TrustedProxyAuth.TryFromHeaders("alice/../etc", "acme", "viewer", out _)); // traversal id Assert.False(TrustedProxyAuth.TryFromHeaders("alice", "", "viewer", out _)); // empty tenant + Assert.False(TrustedProxyAuth.TryFromHeaders("alice", "..", "viewer", out _)); // traversal tenant } + + [Theory] + [InlineData("acme")] + [InlineData("acme-corp")] + [InlineData("acme.us_1")] + [InlineData("a")] + public void AuthIds_accepts_safe_ids(string id) => Assert.True(AuthIds.IsValid(id)); + + [Theory] + [InlineData(".")] // current directory + [InlineData("..")] // parent directory — the traversal segment + [InlineData("...")] // all dots, no alphanumeric + [InlineData(".hidden")] // leading dot (dotfile) + [InlineData("-rf")] // leading dash (option-like) + [InlineData("a/b")] // path separator + [InlineData("a\\b")] // windows separator + [InlineData("")] // empty + [InlineData("___")] // no letter or digit + public void AuthIds_rejects_traversal_and_unsafe_ids(string id) => Assert.False(AuthIds.IsValid(id)); } diff --git a/tests/IntentMesh.Tests/WebTests.cs b/tests/IntentMesh.Tests/WebTests.cs index 41b7fb0..941e72e 100644 --- a/tests/IntentMesh.Tests/WebTests.cs +++ b/tests/IntentMesh.Tests/WebTests.cs @@ -74,6 +74,20 @@ public async Task Health_and_readiness_endpoints_respond() finally { Cleanup(f, runs); } } + [Fact] + public async Task An_oversized_api_body_is_rejected() + { + Environment.SetEnvironmentVariable("INTENTMESH_WEB_TOKEN", null); + var (f, runs) = Make(); + try + { + var huge = new string('x', 300 * 1024); // > 256 KB cap + var resp = await f.CreateClient().PostAsJsonAsync("/api/run", new { prompt = huge }); + Assert.Equal(HttpStatusCode.RequestEntityTooLarge, resp.StatusCode); + } + finally { Cleanup(f, runs); } + } + [Fact] public async Task A_run_persists_and_appears_in_history() {