From d5feabeaf9199225f11ee7afe323f18e62bbc1fd Mon Sep 17 00:00:00 2001 From: Chad Sandor Date: Sun, 21 Jun 2026 06:15:31 -0400 Subject: [PATCH] =?UTF-8?q?fix:=20fifth=20review=20pass=20=E2=80=94=20serv?= =?UTF-8?q?ice=20&=20integration=20hardening=20(7=20High=20+=204=20Medium)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit High: - /api/export no longer honors caller-supplied approvals (it signed bundles with them, bypassing server-issued challenges + approver authz); always runs unapproved. - /challenges and /approve verify the stored bundle signature before re-running saved.Prompt (409 on failure) — a tampered artifact can't seed a signed approved run. - per-file delete approvals over the web: challenges minted per unit (bare node id, or node#fileRef for destructive delete) via new PolicyView.ApprovalRefs. - fail-closed persistence: /api/run and /approve return 503 (no leaked detail) when a run can't be durably stored, instead of 200 with an unsaved result. - rate limiting (built-in framework limiter, no new dep): per-client /api cap + a stricter policy on POST /api/auth/token. - imported-OpenAPI confirmation keys on the inferred side effect, not the HTTP verb, so a side-effecting GET/HEAD is still gated. - MCP path policy enforces the normalized typed action path (FsRead/FsWrite.Path), not only fixed raw arg keys — closes a custom-mapper bypass. Medium: - read endpoints require >= viewer (roleless principal gets 403). - legacy INTENTMESH_WEB_TOKEN reduced to operator+viewer (no approver), documented dev-only. - security headers (CSP script-src 'self', nosniff, DENY, no-referrer); SPA token moved to sessionStorage. - release hardening: repo NuGet.config (single-source mapping), Docker restore --locked-mode, attestation split into a separate least-privilege CI job. Note: findings overlapping v1.9.2/v1.10.x were re-verified; AllowedHosts, key floor, draft gate, and SHA-pinning remain resolved. 16 new tests; 240 passing + 3 skipped. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 31 +++- CHANGELOG.md | 34 ++++ Directory.Build.props | 4 +- NuGet.config | 16 ++ README.md | 6 +- docs/DEPLOYMENT.md | 23 ++- docs/MATURITY.md | 14 +- src/IntentMesh.Core/IntentMeshRuntime.cs | 5 +- src/IntentMesh.Core/RunResult.cs | 6 +- src/IntentMesh.Integrations/McpProxy.cs | 22 ++- .../OpenApiImporter.cs | 8 +- src/IntentMesh.Web/Dockerfile | 7 +- src/IntentMesh.Web/Program.cs | 169 ++++++++++++++---- src/IntentMesh.Web/wwwroot/app.js | 11 +- tests/IntentMesh.Tests/IntegrationTests.cs | 40 +++++ tests/IntentMesh.Tests/WebAuthzTests.cs | 101 ++++++++++- tests/IntentMesh.Tests/WebTests.cs | 15 ++ 17 files changed, 438 insertions(+), 74 deletions(-) create mode 100644 NuGet.config diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cf6a85e..ca5bcbd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,9 +15,7 @@ jobs: build-test: runs-on: ubuntu-24.04 # pinned runner image for reproducibility permissions: - contents: read - id-token: write # for build-provenance attestation - attestations: write + contents: read # build/test/pack run with NO write scopes — provenance signing is a separate job steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 @@ -83,12 +81,6 @@ jobs: done ( cd artifacts/pack && sha256sum *.nupkg > SHA256SUMS && cat SHA256SUMS ) - - name: Attest build provenance for the packages - if: github.event_name == 'push' # attestation requires write tokens (not available on fork PRs) - uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2 - with: - subject-path: 'artifacts/pack/*.nupkg' - - name: Upload packages + checksums uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: @@ -97,3 +89,24 @@ jobs: artifacts/pack/*.nupkg artifacts/pack/SHA256SUMS if-no-files-found: error + + # Build-provenance attestation runs in a SEPARATE job that holds the only id-token/attestations write + # scopes — so the build/test/pack job above has no write tokens. Push events only (fork PRs can't sign). + attest: + needs: build-test + if: github.event_name == 'push' + runs-on: ubuntu-24.04 + permissions: + contents: read + id-token: write + attestations: write + steps: + - name: Download packed artifacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: intentmesh-packages + path: artifacts/pack + - name: Attest build provenance for the packages + uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2 + with: + subject-path: 'artifacts/pack/*.nupkg' diff --git a/CHANGELOG.md b/CHANGELOG.md index 7dd3e24..9f4a045 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,40 @@ All notable changes to IntentMesh. Claims are test-backed; see [docs/MATURITY.md](docs/MATURITY.md) for the production-ready / experimental / future breakdown. +## v1.11.0 — Service & integration hardening (fifth review pass) + +Closes a fifth external review (7 High + 4 Medium). **240 passing + 3 env-gated skipped.** + +High: +- **Export no longer honors caller approvals** — `/api/export` signed/bundle output was running with + caller-supplied approvals, bypassing server-issued challenges + approver authorization. It now always + runs unapproved; a signed approved bundle comes only from `/api/runs/{id}/approve`. +- **Verify-before-rerun** — `/challenges` and `/approve` now verify the stored bundle signature before + re-running `saved.Prompt` (HTTP 409 on failure), so a tampered artifact can't become input to a newly + signed approved run. +- **Per-file delete approvals over the web** — challenges are minted per approval *unit*: a bare node id, + or `node#fileRef` for a destructive delete, so granular per-file consent works through the API (the + core already required `node#fileRef`). +- **Fail-closed persistence** — `/api/run` and `/approve` return `503` (no leaked exception text) when a + run can't be durably, verifiably stored, instead of `200` with an unsaved result. +- **Rate limiting** — a per-client fixed-window limiter (built-in framework limiter, no new dependency) + on `/api`, with a stricter policy on `POST /api/auth/token` to blunt credential brute force. +- **Side-effecting GET/HEAD is gated** — imported-OpenAPI confirmation now keys on the inferred side + effect, not the HTTP verb, so a `sendReminder`-style GET still requires confirmation. +- **MCP custom-mapper path enforcement** — path policy now checks the normalized typed action path + (`FsRead/FsWrite.Path`), not only fixed raw argument keys, closing a bypass for mappers that use a + non-standard path argument name. + +Medium: +- **Reads are role-gated** — every read endpoint requires at least `viewer` (a roleless principal gets 403). +- **Legacy `INTENTMESH_WEB_TOKEN`** reduced to operator+viewer (no approver) and documented dev-only. +- **Security headers** — strict CSP (`script-src 'self'`), `nosniff`, `DENY` framing, `no-referrer`; the + SPA keeps its bearer token in `sessionStorage` (tab-scoped), not `localStorage`. +- **Release hardening** — repo `NuGet.config` with single-source package mapping; Docker restore uses + `--locked-mode`; build-provenance attestation moved to a separate least-privilege job so build/test/pack + hold no write tokens. (Residual, documented: NuGet package signing needs a code-signing cert; base-image + digest pinning needs registry access.) + ## 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.** diff --git a/Directory.Build.props b/Directory.Build.props index 3397faa..16e9535 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.1 + 1.11.0 Chad Sandor wyckit IntentMesh @@ -18,7 +18,7 @@ MIT false - 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. + v1.11.0 — service hardening: server-mediated approvals on export, verify-before-rerun, per-file web approvals, fail-closed persistence, rate limiting, CSP/security headers, side-effecting-GET gating, MCP typed-path enforcement. See CHANGELOG.md. true diff --git a/NuGet.config b/NuGet.config new file mode 100644 index 0000000..c43e28b --- /dev/null +++ b/NuGet.config @@ -0,0 +1,16 @@ + + + + + + + + + + + + + diff --git a/README.md b/README.md index 8c120ab..15124a2 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 # 232 passing (+3 env-gated skipped) +dotnet test IntentMesh.slnx # 240 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). **232 passing (+3 env-gated skipped) tests · IntentBench 25/25 · TLM 7/7.** +operator workflow, audit operations). **240 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 232 passing (+3 env-gated skipped).** Five demo +7/7 round-trip verify; typed action contracts across four domains. **xUnit 240 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/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 8e891f9..61fdbb8 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -66,10 +66,25 @@ headers** so a caller can't spoof identity. another tenant returns `404`. Caller-asserted approvals are ignored — approve a gated node by fetching `POST /api/runs/{id}/challenges` and posting the returned challenge(s) to `POST /api/runs/{id}/approve`. -### Legacy single-token (dev/simple) - -`INTENTMESH_WEB_TOKEN` still works as a single shared bearer mapped to a `default`-tenant principal -(operator+approver+viewer). Prefer Mode A/B for anything multi-user. +### Legacy single-token (dev/local only — NOT for production) + +`INTENTMESH_WEB_TOKEN` is a single shared bearer mapped to a `default`-tenant principal with +**operator + viewer only** (deliberately *not* approver — a shared token must not be able to +self-approve gated nodes). It exists for local/dev convenience; **do not use it in production** — use +Mode A (token) or Mode B (proxy), which give per-principal identity and real approver separation. + +## Hardening notes + +- **Rate limiting** — a per-client fixed-window limiter (keyed on `X-Forwarded-For` from the proxy, else + the socket IP) caps `/api` traffic; `POST /api/auth/token` has a stricter limit to blunt credential + brute force. Behind a proxy, ensure it sets a truthful `X-Forwarded-For`. +- **Security headers** — every response carries a strict `Content-Security-Policy` (`script-src 'self'`), + `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, and `Referrer-Policy: no-referrer`. The SPA + keeps its bearer token in `sessionStorage` (tab-scoped), not `localStorage`. +- **Reads are role-gated** — every read endpoint requires at least the `viewer` role (operator/approver/ + admin also satisfy it); a principal with no recognized role cannot read run data. +- **Fail-closed persistence** — if a run (or an approved run) cannot be durably, verifiably stored, the + API returns `503` rather than a `200` with an unsaved result. ## TLS / reverse-proxy contract diff --git a/docs/MATURITY.md b/docs/MATURITY.md index 6fa0647..7cab1aa 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` — **232 passing, 3 env-gated skipped**). Nothing here is aspirational unless it says so. +true (`dotnet test IntentMesh.slnx` — **240 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, @@ -24,7 +24,8 @@ true (`dotnet test IntentMesh.slnx` — **232 passing, 3 env-gated skipped**). N | MCP proxy gates intent before forwarding (stdio + HTTP/SSE) | IntegrationTests — blocked call never reaches the server | | OpenAPI import (JSON+YAML, `$ref`, semantic inference) | IntegrationTests | | Operator workflow: history, approval queue, replay diff, artifact viewer, why-blocked | ExplainTests + Web endpoints, smoke-tested | -| Multi-tenant authz: principal/tenant/role identity, tenant-isolated run store, server-issued approval challenges | AuthTests, WebAuthzTests — cross-tenant run is 404, viewer can't run, only a server-minted challenge approves | +| Multi-tenant authz: principal/tenant/role identity, tenant-isolated run store, server-issued approval challenges (incl. per-file delete `node#fileRef`) | AuthTests, WebAuthzTests — cross-tenant run is 404, roleless principal can't read, viewer can't run, only a server-minted challenge approves (export/explain don't honor caller approvals) | +| Service hardening: per-client rate limiting, CSP/security headers, verify-before-rerun, fail-closed persistence | WebTests, WebAuthzTests — auth endpoint 429s per client, CSP present, tampered run is 409, lost persistence is 503 | | Stable SDK surface + minimal host template | SdkTests; `templates/IntentMesh.Host.Template` builds **and runs** | ## 🧪 Experimental — works, reference-grade, not hardened @@ -48,9 +49,12 @@ true (`dotnet test IntentMesh.slnx` — **232 passing, 3 env-gated skipped**). N backend is future). Put the runs dir on an encrypted volume meanwhile — see [DEPLOYMENT.md](DEPLOYMENT.md). - **Declarative policy DSL** — see [POLICY-AUTHORING.md](POLICY-AUTHORING.md) (C# authoritative today). - **Live RSRM hot-load** of the `im-*` bundle. -- **Identity-provider provisioning** (SSO/SCIM) and **rate limiting / quotas** on the Control Room API. - The authz boundary is built (principal/tenant/role, server-issued approvals — see Proven); what remains - future is bulk provisioning, a managed principal store, and per-tenant rate limits. +- **Identity-provider provisioning** (SSO/SCIM) and a **managed principal store**. The authz boundary, + per-client rate limiting, CSP/security headers, and server-mediated approvals are built (see Proven); + what remains future is bulk/federated provisioning and per-tenant quota policies. +- **Signed NuGet packages + digest-pinned base images.** Build-provenance attestation + SHA256SUMS ship + today (and attestation now runs in an isolated least-privilege job); cryptographic package signing needs + a code-signing certificate, and base-image digest pinning needs registry access — both deployment-owned. - **Fuzz / mutation testing + enforced coverage thresholds** — the suite is example-based today. - **Live-LLM CI gate** — the real-Anthropic test runs in CI only when the `ANTHROPIC_API_KEY` secret is configured (it skips otherwise). The real filesystem-MCP and stdio-MCP E2E paths now DO run in CI. diff --git a/src/IntentMesh.Core/IntentMeshRuntime.cs b/src/IntentMesh.Core/IntentMeshRuntime.cs index 10c642a..065b60a 100644 --- a/src/IntentMesh.Core/IntentMeshRuntime.cs +++ b/src/IntentMesh.Core/IntentMeshRuntime.cs @@ -237,8 +237,11 @@ private static RunResult Project(string prompt, ProposedPlan resolved, IntentGra var policy = graph.Nodes.Where(n => n.Policy is not null).Select(n => { var p = n.Policy!; + var approvalRefs = n.Action is DeleteFilesAction del + ? del.FileRefs.ToList() + : (IReadOnlyList)Array.Empty(); return new PolicyView(n.Id, n.Label, p.Decision.ToString(), p.Risk, p.Reason, p.TriggeredRules, - p.RequiresConfirmation, p.TrustSource, p.Sensitive, p.ExternalSideEffect, p.Destructive); + p.RequiresConfirmation, p.TrustSource, p.Sensitive, p.ExternalSideEffect, p.Destructive, approvalRefs); }).ToList(); var exec = graph.Nodes.Where(n => n.Execution is not null).Select(n => diff --git a/src/IntentMesh.Core/RunResult.cs b/src/IntentMesh.Core/RunResult.cs index 9b45a26..3a14c0c 100644 --- a/src/IntentMesh.Core/RunResult.cs +++ b/src/IntentMesh.Core/RunResult.cs @@ -14,7 +14,11 @@ public sealed record NodeView( public sealed record PolicyView( string NodeId, string Label, string Decision, string Risk, string Reason, IReadOnlyList TriggeredRules, bool RequiresConfirmation, - string TrustSource, bool Sensitive, bool ExternalSideEffect, bool Destructive); + string TrustSource, bool Sensitive, bool ExternalSideEffect, bool Destructive, + // The granular approval units for this node. Empty for a normal node (approve the bare node id); + // for a per-file destructive delete it is the file refs — each is approved as "{NodeId}#{ref}", so + // a UI / API can mint and consent to one approval challenge per file rather than a node-wide blanket. + IReadOnlyList ApprovalRefs); public sealed record ExecView(string NodeId, string Label, bool Ran, bool Halted, string Summary, IReadOnlyList Effects); diff --git a/src/IntentMesh.Integrations/McpProxy.cs b/src/IntentMesh.Integrations/McpProxy.cs index af6f4fc..86af119 100644 --- a/src/IntentMesh.Integrations/McpProxy.cs +++ b/src/IntentMesh.Integrations/McpProxy.cs @@ -152,11 +152,14 @@ public McpGateResult Gate(McpToolCall call, IReadOnlySet? approvals = nu } // Path-safety policy: a filesystem call whose path escapes the allowed root is blocked here, - // before the pipeline runs and before anything is forwarded to the MCP server. Every - // path-bearing argument is checked — including the multi-path `paths` array (read_multiple_files) - // — so a tool whose primary action maps to "." cannot smuggle escaping paths past the gate. + // before the pipeline runs and before anything is forwarded to the MCP server. We check the + // NORMALIZED typed action path(s) — FsReadAction/FsWriteAction.Path — AS WELL AS the raw + // arguments. Checking the typed path is what closes the custom-mapper gap: a per-server mapper + // that reads a non-standard argument name (e.g. "filepath") still produces a typed action whose + // Path is enforced here, even though a raw-key scan over {path,source,destination,paths} would + // miss it. The raw-arg scan remains as defense-in-depth for the multi-path `paths` array. if (_allowedRoot is not null && action is FsReadAction or FsWriteAction) - foreach (var p in CandidatePaths(call.Args)) + foreach (var p in TypedPaths(action).Concat(CandidatePaths(call.Args))) if (PathEscapesRoot(p)) return new McpGateResult( Allowed: false, @@ -306,6 +309,17 @@ private static (TypedAction?, string) MapFsWrite(McpToolCall call) return (new FsWriteAction(path, content ?? ""), $"MCP {call.Tool} → {path}"); } + /// The path(s) carried by the NORMALIZED typed action — enforced regardless of which raw + /// argument name a (possibly custom) mapper read them from. + private static IEnumerable TypedPaths(TypedAction action) + { + switch (action) + { + case FsReadAction r when !string.IsNullOrEmpty(r.Path): yield return r.Path; break; + case FsWriteAction w when !string.IsNullOrEmpty(w.Path): yield return w.Path; break; + } + } + /// Every path-bearing argument for a filesystem tool: the single-path keys plus the /// paths array (read_multiple_files). A paths value is parsed as a JSON array; if /// that fails it is treated as a single raw path (fail-closed — it still gets checked). diff --git a/src/IntentMesh.Integrations/OpenApiImporter.cs b/src/IntentMesh.Integrations/OpenApiImporter.cs index 13cf8f9..263ed3b 100644 --- a/src/IntentMesh.Integrations/OpenApiImporter.cs +++ b/src/IntentMesh.Integrations/OpenApiImporter.cs @@ -217,8 +217,12 @@ public static ImportedContract ToContract(ToolSchema schema, bool trusted = fals // Capability the action requires, inferred from the side effect + operation semantics. var capability = InferCapability(sideEffect, schema.Name, schema.Summary); - // Confirmation required when: mutating method + non-trivial side effect (after the floor above). - bool requiresConfirmation = mutating && sideEffect != "none"; + // Confirmation is required whenever the operation has a real side effect — keyed on the SIDE + // EFFECT, not the HTTP verb. A side-effecting GET/HEAD (e.g. an operation named "sendInvite" or + // "purgeCache" exposed over GET) must still be gated; tying confirmation to mutating verbs let + // such operations bypass it. A genuinely safe read infers side-effect "none" and stays ungated; + // a trusted spec can still mark a read explicitly safe via SideEffectHint="none". + bool requiresConfirmation = sideEffect != "none"; return new ImportedContract( Kind: kind, diff --git a/src/IntentMesh.Web/Dockerfile b/src/IntentMesh.Web/Dockerfile index 3a97417..1e31e4b 100644 --- a/src/IntentMesh.Web/Dockerfile +++ b/src/IntentMesh.Web/Dockerfile @@ -7,8 +7,11 @@ FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build WORKDIR /src COPY . . -RUN dotnet restore IntentMesh.slnx \ - && dotnet publish src/IntentMesh.Web/IntentMesh.Web.csproj -c Release -o /app +# Locked restore (honors packages.lock.json + the repo NuGet.config source mapping) so the image is +# built from the exact, pinned dependency set — a drifted lock file fails the build rather than +# silently resolving different packages. +RUN dotnet restore IntentMesh.slnx --locked-mode \ + && dotnet publish src/IntentMesh.Web/IntentMesh.Web.csproj -c Release --no-restore -o /app FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime WORKDIR /app diff --git a/src/IntentMesh.Web/Program.cs b/src/IntentMesh.Web/Program.cs index f75b470..83c254b 100644 --- a/src/IntentMesh.Web/Program.cs +++ b/src/IntentMesh.Web/Program.cs @@ -1,13 +1,42 @@ using System.Net; using System.Security.Cryptography; using System.Text; +using System.Threading.RateLimiting; using IntentMesh.Core; using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.RateLimiting; // 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. var builder = WebApplication.CreateBuilder(args); + +// Rate limiting (built into the shared framework — no extra package). Partitions by the forwarded +// client ip (X-Forwarded-For behind the reverse proxy, else the socket ip); a request with no +// resolvable client key (e.g. an in-process test server) is not limited. A global per-client cap +// covers run/replay/export/storage amplification; the stricter "auth" policy throttles credential +// brute force on POST /api/auth/token. +builder.Services.AddRateLimiter(options => +{ + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + options.GlobalLimiter = PartitionedRateLimiter.Create(ctx => + { + var key = ClientKey(ctx); + return key is null + ? RateLimitPartition.GetNoLimiter("unkeyed") + : RateLimitPartition.GetFixedWindowLimiter(key, _ => new FixedWindowRateLimiterOptions + { PermitLimit = 600, Window = TimeSpan.FromMinutes(1), QueueLimit = 0 }); + }); + options.AddPolicy("auth", ctx => + { + var key = ClientKey(ctx); + return key is null + ? RateLimitPartition.GetNoLimiter("unkeyed") + : RateLimitPartition.GetFixedWindowLimiter("auth:" + key, _ => new FixedWindowRateLimiterOptions + { PermitLimit = 10, Window = TimeSpan.FromMinutes(1), QueueLimit = 0 }); + }); +}); + var app = builder.Build(); // Load the symbolic bundle once. Walk up from CWD, then from the binary location, to find @@ -105,6 +134,23 @@ new { id = 5, title = "Data agent", prompt = "Summarize signups by plan from the analytics database, delete old records, and email the client a report." }, }; +// Security headers on every response — a strict CSP (the SPA is dependency-free: its only script is the +// same-origin app.js), plus anti-sniff / anti-framing / referrer hygiene. style-src allows inline styles +// the dependency-free UI uses; no remote origins are permitted. +app.Use(async (context, next) => +{ + var h = context.Response.Headers; + h["X-Content-Type-Options"] = "nosniff"; + h["X-Frame-Options"] = "DENY"; + h["Referrer-Policy"] = "no-referrer"; + h["Content-Security-Policy"] = + "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; " + + "connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'"; + await next(); +}); + +app.UseRateLimiter(); + // ── Auth middleware: resolve the calling principal for every /api request ────────────────────────── // Precedence: trusted-proxy > built-in token > legacy single-token > dev-loopback. The resolved // principal is attached to HttpContext.Items["principal"]; endpoints role-gate off it and scope every @@ -151,10 +197,12 @@ } else if (webToken is not null) { - // Legacy single shared token → a default-tenant principal with read+run+approve. + // Legacy single shared token (dev/local only — see docs) → a default-tenant principal with + // read+run, but NOT approver: a shared bearer must not be able to self-approve gated nodes, so + // approvals still require a real approver principal (token mode) or trusted-proxy identity. if (ConstTimeEq(BearerOf(context), webToken)) principal = new AuthPrincipal("operator", "default", - Roles.Set(new[] { Roles.Operator, Roles.Approver, Roles.Viewer })); + Roles.Set(new[] { Roles.Operator, Roles.Viewer })); } else if (loopback) { @@ -210,7 +258,7 @@ roles = p.Roles, expiresAt = exp, }); -}); +}).RequireRateLimiting("auth"); /// The authenticated caller's identity (principal, tenant, roles). app.MapGet("/api/auth/whoami", (HttpContext http) => @@ -232,21 +280,25 @@ // challenge (POST /api/runs/{id}/approve). The first pass always runs with no approvals. var result = runtime.Run(prompt, Workspace.CreateDemo(), new HashSet()); + // Fail CLOSED on persistence: a run that cannot be durably, verifiably recorded is treated as + // failed (503) rather than returning 200 with an unsaved result — an audited action must not look + // successful when its signed record was lost. The exception detail is logged server-side only. var store = StoreFor(p.TenantId); + string runId; try { - var runId = store.Save(TraceBundleBuilder.From(result, new List(), keyProvider)); + runId = store.Save(TraceBundleBuilder.From(result, new List(), keyProvider)); store.RecordOwner(runId, new RunOwner(p.PrincipalId, p.TenantId, NowUnix())); - http.Response.Headers["X-Run-Id"] = runId; - if (req.Approvals is { Length: > 0 }) - http.Response.Headers["X-Approvals-Ignored"] = "approve gated nodes via POST /api/runs/{id}/approve with a server-issued challenge"; } catch (Exception ex) { - Console.Error.WriteLine($"WARN: could not persist run: {ex.Message}"); - http.Response.Headers["X-Persist-Error"] = ex.Message.Replace('\n', ' ').Replace('\r', ' '); + Console.Error.WriteLine($"ERROR: could not persist run: {ex}"); + return Results.Json(new { error = "run could not be durably persisted" }, statusCode: 503); } + http.Response.Headers["X-Run-Id"] = runId; + if (req.Approvals is { Length: > 0 }) + http.Response.Headers["X-Approvals-Ignored"] = "approve gated nodes via POST /api/runs/{id}/approve with a server-issued challenge"; return Results.Json(result); }); @@ -254,19 +306,28 @@ // All reads are tenant-scoped: any authenticated member of the tenant may view its runs; a run id from // another tenant simply isn't found (404), so existence never leaks across tenants. -app.MapGet("/api/runs", (HttpContext http) => Results.Json(StoreFor(Principal(http).TenantId).ListSummaries())); +app.MapGet("/api/runs", (HttpContext http) => +{ + var p = Principal(http); + if (!CanRead(p)) return Forbidden(Roles.Viewer); + return Results.Json(StoreFor(p.TenantId).ListSummaries()); +}); app.MapGet("/api/runs/{id}", (string id, HttpContext http) => { - try { return Results.Json(StoreFor(Principal(http).TenantId).Load(id)); } + var p = Principal(http); + if (!CanRead(p)) return Forbidden(Roles.Viewer); + try { return Results.Json(StoreFor(p.TenantId).Load(id)); } catch (Exception ex) when (ex is FileNotFoundException or ArgumentException) { return Results.NotFound(new { error = $"no run '{id}'" }); } }); app.MapGet("/api/runs/{id}/artifact/{name}", (string id, string name, HttpContext http) => { + var p = Principal(http); + if (!CanRead(p)) return Forbidden(Roles.Viewer); try { - var json = StoreFor(Principal(http).TenantId).ReadArtifact(id, name); + var json = StoreFor(p.TenantId).ReadArtifact(id, name); return json is not null ? Results.Text(json, "application/json") : Results.NotFound(new { error = $"no artifact '{name}'", available = TraceBundleBuilder.ArtifactNames }); @@ -276,9 +337,11 @@ app.MapGet("/api/runs/{id}/verify", (string id, HttpContext http) => { + var p = Principal(http); + if (!CanRead(p)) return Forbidden(Roles.Viewer); try { - var store = StoreFor(Principal(http).TenantId); + var store = StoreFor(p.TenantId); var bundle = store.Load(id); return Results.Json(new { @@ -293,9 +356,11 @@ app.MapPost("/api/runs/{id}/replay", (string id, HttpContext http) => { + var p = Principal(http); + if (!CanRead(p)) return Forbidden(Roles.Viewer); try { - var saved = StoreFor(Principal(http).TenantId).Load(id); + var saved = StoreFor(p.TenantId).Load(id); var replay = RunReplay.Reproduce(runtime, Workspace.CreateDemo(), saved, keyProvider); return Results.Json(new { @@ -310,8 +375,10 @@ }); // ── Server-issued approval flow (replaces caller-asserted approvals) ─────────────────────────────── -/// Mint a signed approval challenge for each gated (Confirm) node of a run. The challenge is bound to -/// {runId, nodeId, tenant} — it is the ONLY thing /approve will accept. Requires the approver role. +/// Mint a signed approval challenge for each gated (Confirm) approval UNIT of a run. The unit is the +/// bare node id, except for a per-file destructive delete where it is "{nodeId}#{fileRef}" — so each +/// file gets its own challenge and granular consent is preserved. The challenge is bound to +/// {runId, unit, tenant}; it is the ONLY thing /approve will accept. Requires the approver role. app.MapPost("/api/runs/{id}/challenges", (string id, HttpContext http) => { var p = Principal(http); @@ -320,17 +387,27 @@ try { saved = StoreFor(p.TenantId).Load(id); } catch (Exception ex) when (ex is FileNotFoundException or ArgumentException) { return Results.NotFound(new { error = $"no run '{id}'" }); } + // Trust the stored run BEFORE re-running it: a tampered bundle must not become input to a freshly + // signed approved run. (Mirrors RunReplay, which verifies before re-execution.) + if (!TraceBundleBuilder.VerifySignature(saved, keyProvider)) + return Results.Json(new { error = "stored run failed integrity verification" }, statusCode: 409); + var res = runtime.Run(saved.Prompt, Workspace.CreateDemo(), new HashSet()); var now = NowUnix(); var exp = now + ChallengeTtlSeconds; - var challenges = res.Policy.Where(x => x.RequiresConfirmation).Select(x => new - { - nodeId = x.NodeId, - label = x.Label, - reason = x.Reason, - challenge = challengeSvc.Mint(id, x.NodeId, p.TenantId, now, exp, Nonce()), - expiresAt = exp, - }).ToList(); + var challenges = res.Policy.Where(x => x.RequiresConfirmation).SelectMany(x => + // No refs → one challenge for the bare node; per-file delete → one challenge per "node#ref". + (x.ApprovalRefs.Count == 0 ? new[] { (unit: x.NodeId, fileRef: (string?)null) } + : x.ApprovalRefs.Select(r => (unit: $"{x.NodeId}#{r}", fileRef: (string?)r)).ToArray()) + .Select(u => new + { + nodeId = x.NodeId, + fileRef = u.fileRef, + label = x.Label, + reason = x.Reason, + challenge = challengeSvc.Mint(id, u.unit, p.TenantId, now, exp, Nonce()), + expiresAt = exp, + })).ToList(); return Results.Json(new { runId = id, challenges }); }); @@ -341,31 +418,37 @@ { var p = Principal(http); if (!p.Has(Roles.Approver)) return Forbidden(Roles.Approver); + var store = StoreFor(p.TenantId); TraceBundle saved; - try { saved = StoreFor(p.TenantId).Load(id); } + try { saved = store.Load(id); } catch (Exception ex) when (ex is FileNotFoundException or ArgumentException) { return Results.NotFound(new { error = $"no run '{id}'" }); } + // Trust the stored run BEFORE re-running it under approval — a tampered bundle must never become + // input to a newly signed approved run. + if (!TraceBundleBuilder.VerifySignature(saved, keyProvider)) + return Results.Json(new { error = "stored run failed integrity verification" }, statusCode: 409); + var now = NowUnix(); var approved = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var token in req.Challenges ?? Array.Empty()) - if (challengeSvc.TryVerify(token, id, p.TenantId, now, out var node)) - approved.Add(node); + if (challengeSvc.TryVerify(token, id, p.TenantId, now, out var unit)) + approved.Add(unit); // unit is a bare node id, or "node#fileRef" for a per-file delete if (approved.Count == 0) return Results.Json(new { error = "no valid approval challenge presented" }, statusCode: 400); var result = runtime.Run(saved.Prompt, Workspace.CreateDemo(), approved); - var store = StoreFor(p.TenantId); + string newId; try { - var newId = store.Save(TraceBundleBuilder.From(result, approved.ToList(), keyProvider)); + newId = store.Save(TraceBundleBuilder.From(result, approved.ToList(), keyProvider)); store.RecordOwner(newId, new RunOwner(p.PrincipalId, p.TenantId, now)); - http.Response.Headers["X-Run-Id"] = newId; } catch (Exception ex) { - Console.Error.WriteLine($"WARN: could not persist approved run: {ex.Message}"); - http.Response.Headers["X-Persist-Error"] = ex.Message.Replace('\n', ' ').Replace('\r', ' '); + Console.Error.WriteLine($"ERROR: could not persist approved run: {ex}"); + return Results.Json(new { error = "approved run could not be durably persisted" }, statusCode: 503); } + http.Response.Headers["X-Run-Id"] = newId; return Results.Json(result); }); @@ -380,18 +463,21 @@ }); // Export the run as the canonical, deterministic audit artifact (replayable; no timestamps). +// Caller-asserted approvals are NOT honored here: export would otherwise SIGN a bundle showing approved +// actions without going through server-issued challenges + approver authorization. Export always runs +// unapproved; to obtain a signed approved bundle, approve via /api/runs/{id}/approve (which persists it) +// and fetch it from /api/runs/{id}. app.MapPost("/api/export", (ExportRequest req, HttpContext http) => { if (!Principal(http).Has(Roles.Operator)) return Forbidden(Roles.Operator); var prompt = (req.Prompt ?? "").Trim(); if (string.IsNullOrEmpty(prompt)) return Results.BadRequest(new { error = "empty prompt" }); - var approvals = (req.Approvals ?? Array.Empty()).ToHashSet(StringComparer.OrdinalIgnoreCase); - var result = runtime.Run(prompt, Workspace.CreateDemo(), approvals); + var result = runtime.Run(prompt, Workspace.CreateDemo(), new HashSet()); return (req.Format ?? "json").ToLowerInvariant() switch { "md" or "markdown" => Results.Text(AuditExporter.ToMarkdown(result), "text/markdown"), "signed" => Results.Json(AuditSigner.Sign(result, keyProvider)), - "bundle" => Results.Json(TraceBundleBuilder.From(result, approvals.ToList(), keyProvider)), + "bundle" => Results.Json(TraceBundleBuilder.From(result, new List(), keyProvider)), _ => Results.Text(AuditExporter.ToJson(result), "application/json"), }; }); @@ -412,6 +498,19 @@ static bool ConstTimeEq(string? a, string b) => !string.IsNullOrEmpty(a) && CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(a), Encoding.UTF8.GetBytes(b)); +// Rate-limit partition key: the forwarded client ip (first X-Forwarded-For hop, set by the reverse +// proxy) if present, else the socket ip. Null when neither is resolvable (e.g. the in-process test +// server) — such requests are not rate-limited. +static string? ClientKey(HttpContext c) +{ + var fwd = c.Request.Headers["X-Forwarded-For"].FirstOrDefault(); + if (!string.IsNullOrWhiteSpace(fwd)) return fwd.Split(',')[0].Trim(); + return c.Connection.RemoteIpAddress?.ToString(); +} + +static bool CanRead(AuthPrincipal p) + => p.Has(Roles.Viewer) || p.Has(Roles.Operator) || p.Has(Roles.Approver); // admin covered by Has + // Per-tenant run store: each tenant's runs live under {runsDir}/t/{tenant} — isolation by construction. // 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 diff --git a/src/IntentMesh.Web/wwwroot/app.js b/src/IntentMesh.Web/wwwroot/app.js index b2cab38..681653c 100644 --- a/src/IntentMesh.Web/wwwroot/app.js +++ b/src/IntentMesh.Web/wwwroot/app.js @@ -1,14 +1,15 @@ 'use strict'; -// When the Control Room is run token-gated (INTENTMESH_WEB_TOKEN set, e.g. for remote access), every -// /api call must carry the token. The operator stores it once via localStorage['intentmesh_token']; -// we attach it to every same-origin /api request so the SPA keeps working under token auth. On a -// loopback/local host no token is configured and nothing is added. +// When the Control Room is run with authentication, every /api call must carry the bearer token. The +// operator stores it once via sessionStorage['intentmesh_token'] — sessionStorage (not localStorage) so +// the credential is scoped to the tab/session and cleared when it closes, rather than persisted to disk +// where it outlives the session and is reachable long-term by any script. We attach it to every +// same-origin /api request. On a loopback/local dev host no token is configured and nothing is added. (() => { const _fetch = window.fetch.bind(window); window.fetch = (url, opts = {}) => { if (typeof url === 'string' && url.startsWith('/api')) { - const tok = localStorage.getItem('intentmesh_token'); + const tok = sessionStorage.getItem('intentmesh_token'); if (tok) opts = { ...opts, headers: { ...(opts.headers || {}), 'X-Api-Token': tok } }; } return _fetch(url, opts); diff --git a/tests/IntentMesh.Tests/IntegrationTests.cs b/tests/IntentMesh.Tests/IntegrationTests.cs index 6c844a2..2b1b8e6 100644 --- a/tests/IntentMesh.Tests/IntegrationTests.cs +++ b/tests/IntentMesh.Tests/IntegrationTests.cs @@ -319,6 +319,26 @@ public void McpProxy_allows_a_read_inside_the_allowed_root() finally { Directory.Delete(root, true); } } + [Fact] + public void McpProxy_custom_mapper_with_nonstandard_arg_name_still_enforces_path_policy() + { + var root = TempRoot(); + try + { + var outside = OperatingSystem.IsWindows() ? @"C:\Windows\win.ini" : "/etc/passwd"; + // A per-server mapper that reads a NON-standard argument name ("target") — not one of the raw + // keys CandidatePaths scans — but produces a typed FsReadAction whose Path must still be gated. + var proxy = new McpProxy(Runtime(), Workspace.CreateDemo(), allowedRoot: root, + customMapper: call => call.Args.TryGetValue("target", out var t) + ? ((TypedAction?)new FsReadAction(t), $"read {t}") + : (null, "no target")); + var res = proxy.Gate(new McpToolCall("grab", new Dictionary { ["target"] = outside })); + Assert.False(res.Allowed); + Assert.Contains("path policy", res.Reason, StringComparison.OrdinalIgnoreCase); + } + finally { Directory.Delete(root, true); } + } + [Fact] public void McpProxy_fs_write_is_gated_then_allowed_with_approval() { @@ -421,6 +441,26 @@ public void OpenApiImporter_ToContract_get_schema_does_not_require_confirmation( "GET with no side effect must not require confirmation."); } + /// + /// A side-effecting operation exposed over GET/HEAD must STILL require confirmation — gating is keyed + /// on the inferred side effect, not the HTTP verb, so a "sendReminder"-style GET cannot bypass it. + /// + [Fact] + public void OpenApiImporter_ToContract_side_effecting_get_still_requires_confirmation() + { + var schema = new ToolSchema( + Name: "send_reminder", + Method: "GET", + Summary: "Send a reminder email to the customer.", + Parameters: new[] { "customer_id" }); + + var contract = OpenApiImporter.ToContract(schema); + + Assert.Equal("email-send", contract.SideEffect); + Assert.True(contract.RequiresConfirmation, + "a side-effecting GET must still be gated — confirmation keys on side effect, not verb."); + } + /// /// A DELETE without a risk hint infers Risk=high from the method. /// diff --git a/tests/IntentMesh.Tests/WebAuthzTests.cs b/tests/IntentMesh.Tests/WebAuthzTests.cs index 383a910..63a3b46 100644 --- a/tests/IntentMesh.Tests/WebAuthzTests.cs +++ b/tests/IntentMesh.Tests/WebAuthzTests.cs @@ -22,11 +22,13 @@ public sealed class WebAuthzTests private const string AliceKey = "sk-alice-secret"; // acme: operator+approver+viewer private const string CarolKey = "sk-carol-secret"; // acme: viewer only private const string BobKey = "sk-bob-secret"; // globex: operator+approver+viewer + private const string DanKey = "sk-dan-secret"; // acme: NO roles private static string PrincipalsJson() => "[" + $"{{\"id\":\"alice\",\"tenant\":\"acme\",\"roles\":[\"operator\",\"approver\",\"viewer\"],\"apiKeyHash\":\"{PrincipalStore.HashApiKey(AliceKey)}\"}}," + $"{{\"id\":\"carol\",\"tenant\":\"acme\",\"roles\":[\"viewer\"],\"apiKeyHash\":\"{PrincipalStore.HashApiKey(CarolKey)}\"}}," + + $"{{\"id\":\"dan\",\"tenant\":\"acme\",\"roles\":[],\"apiKeyHash\":\"{PrincipalStore.HashApiKey(DanKey)}\"}}," + $"{{\"id\":\"bob\",\"tenant\":\"globex\",\"roles\":[\"operator\",\"approver\",\"viewer\"],\"apiKeyHash\":\"{PrincipalStore.HashApiKey(BobKey)}\"}}" + "]"; @@ -67,8 +69,9 @@ private static void Cleanup(WebApplicationFactory f, string runsDir) private sealed record TokenResp(string token, string principal, string tenant, string[] roles, long expiresAt); private sealed record WhoResp(string principal, string tenant, string[] roles); - private sealed record Challenge(string nodeId, string label, string reason, string challenge, long expiresAt); + private sealed record Challenge(string nodeId, string? fileRef, string label, string reason, string challenge, long expiresAt); private sealed record ChallengesResp(string runId, Challenge[] challenges); + private sealed record BundleApprovals(string bundleSignature, string[] approvals); private static async Task TokenFor(WebApplicationFactory f, string apiKey) { @@ -209,6 +212,102 @@ public async Task The_approver_role_is_required_to_approve() finally { Cleanup(f, runs); } } + [Fact] + public async Task A_principal_with_no_role_cannot_read_runs() + { + var (f, runs) = MakeTokenMode(); + try + { + var dan = await ClientFor(f, DanKey); // authenticated, but no roles + Assert.Equal(HttpStatusCode.Forbidden, (await dan.GetAsync("/api/runs")).StatusCode); + } + finally { Cleanup(f, runs); } + } + + [Fact] + public async Task Export_does_not_honor_caller_supplied_approvals() + { + var (f, runs) = MakeTokenMode(); + try + { + var alice = await ClientFor(f, AliceKey); + var resp = await alice.PostAsJsonAsync("/api/export", + new { prompt = Demo1, approvals = new[] { "n1", "n2" }, format = "bundle" }); + resp.EnsureSuccessStatusCode(); + var bundle = (await resp.Content.ReadFromJsonAsync())!; + Assert.Empty(bundle.approvals); // caller approvals ignored — the signed bundle has none + } + finally { Cleanup(f, runs); } + } + + [Fact] + public async Task A_tampered_stored_run_fails_integrity_before_approval() + { + var (f, runs) = MakeTokenMode(); + try + { + var alice = await ClientFor(f, AliceKey); + var id = (await alice.PostAsJsonAsync("/api/run", new { prompt = Demo1 })) + .Headers.GetValues("X-Run-Id").First(); + + // Tamper the stored signed bundle (acme tenant partition) so its signature no longer verifies. + var bundlePath = Path.Combine(runs, "t", "acme", id, "bundle.json"); + File.WriteAllText(bundlePath, File.ReadAllText(bundlePath).Replace("Friday", "Monday")); + + // Both challenge minting and approval must refuse to re-run a bundle that fails verification. + Assert.Equal(HttpStatusCode.Conflict, (await alice.PostAsync($"/api/runs/{id}/challenges", null)).StatusCode); + Assert.Equal(HttpStatusCode.Conflict, + (await alice.PostAsJsonAsync($"/api/runs/{id}/approve", new { challenges = new[] { "x" } })).StatusCode); + } + finally { Cleanup(f, runs); } + } + + [Fact] + public async Task Per_file_delete_confirmations_are_minted_and_approved_per_file() + { + const string deletePrompt = "Clean up my downloads and delete anything that looks like junk."; + var (f, runs) = MakeTokenMode(); + try + { + var alice = await ClientFor(f, AliceKey); + var id = (await alice.PostAsJsonAsync("/api/run", new { prompt = deletePrompt })) + .Headers.GetValues("X-Run-Id").First(); + + var chResp = await alice.PostAsync($"/api/runs/{id}/challenges", null); + chResp.EnsureSuccessStatusCode(); + var challenges = (await chResp.Content.ReadFromJsonAsync())!; + + // A destructive delete is offered as PER-FILE challenges (node#fileRef), not one node-wide grant. + Assert.Contains(challenges.challenges, c => c.fileRef is not null); + + var approve = await alice.PostAsJsonAsync($"/api/runs/{id}/approve", + new { challenges = challenges.challenges.Select(x => x.challenge).ToArray() }); + approve.EnsureSuccessStatusCode(); + Assert.NotEqual(id, approve.Headers.GetValues("X-Run-Id").First()); + } + finally { Cleanup(f, runs); } + } + + [Fact] + public async Task Auth_endpoint_is_rate_limited_per_client() + { + var (f, runs) = MakeTokenMode(); + try + { + var codes = new List(); + for (int i = 0; i < 13; i++) + { + var req = new HttpRequestMessage(HttpMethod.Post, "/api/auth/token") + { Content = JsonContent.Create(new { apiKey = "nope" }) }; + req.Headers.Add("X-Forwarded-For", "203.0.113.7"); // a fixed client ip → its own partition + codes.Add((await f.CreateClient().SendAsync(req)).StatusCode); + } + // The "auth" policy permits 10/min per client; the surplus is rejected with 429. + Assert.Contains(HttpStatusCode.TooManyRequests, codes); + } + finally { Cleanup(f, runs); } + } + [Fact] public async Task Trusted_proxy_headers_are_honored_only_with_the_proxy_secret() { diff --git a/tests/IntentMesh.Tests/WebTests.cs b/tests/IntentMesh.Tests/WebTests.cs index 941e72e..08d209f 100644 --- a/tests/IntentMesh.Tests/WebTests.cs +++ b/tests/IntentMesh.Tests/WebTests.cs @@ -74,6 +74,21 @@ public async Task Health_and_readiness_endpoints_respond() finally { Cleanup(f, runs); } } + [Fact] + public async Task Security_headers_are_present_on_responses() + { + Environment.SetEnvironmentVariable("INTENTMESH_WEB_TOKEN", null); + var (f, runs) = Make(); + try + { + var resp = await f.CreateClient().GetAsync("/api/demos"); + Assert.Contains("script-src 'self'", resp.Headers.GetValues("Content-Security-Policy").First()); + Assert.Equal("nosniff", resp.Headers.GetValues("X-Content-Type-Options").First()); + Assert.Equal("DENY", resp.Headers.GetValues("X-Frame-Options").First()); + } + finally { Cleanup(f, runs); } + } + [Fact] public async Task An_oversized_api_body_is_rejected() {