From 1fd6e078be121e1a4f1730ac8300d6f0a69780b7 Mon Sep 17 00:00:00 2001 From: ColinM-sys Date: Tue, 7 Apr 2026 01:31:52 -0400 Subject: [PATCH 1/3] fix(policy): block POST to sentry.io to prevent multi-tenant data exfiltration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sentry.io is a multi-tenant SaaS — any client with a project ID can POST to any Sentry project, not just NemoClaw's. The baseline sandbox policy allowed POST to sentry.io with path '/**', which turned the host into a generic exfiltration channel: a compromised agent inside the sandbox could ship stack traces, env vars, file contents, etc. to a Sentry project controlled by an attacker via the public envelope endpoint (https://sentry.io/api//envelope/). Path-pattern restrictions cannot fix this because the project ID is part of the URL and there is no server-side allowlist of legitimate projects. This is a follow-up to #1214 (which added 'protocol: rest' for sentry.io) — that PR closed the wire-protocol gap, this PR closes the remaining HTTP-method-level gap. Changes: - nemoclaw-blueprint/policies/openclaw-sandbox.yaml: drop the 'method: POST, path: /**' allow rule for sentry.io. GET stays allowed because GET has no request body and is harmless for exfil. Side effect: Claude Code's crash telemetry to Sentry is silently dropped. That is the correct tradeoff for a sandbox whose stated goal is preventing data egress, and the sandbox already blocks many similar telemetry channels by default. - test/validate-blueprint.test.ts: walk every endpoint in network_policies, find sentry.io, and assert (a) at least one sentry.io entry exists, (b) no sentry.io entry has a POST allow rule, (c) the GET allow rule is preserved. Verified by stashing the policy fix and re-running: the test correctly fails on main with the unfixed policy, and passes with the fix in place. Closes #1437 --- .../policies/openclaw-sandbox.yaml | 15 +++++- test/validate-blueprint.test.ts | 46 +++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/nemoclaw-blueprint/policies/openclaw-sandbox.yaml b/nemoclaw-blueprint/policies/openclaw-sandbox.yaml index b29eaa2dd..c1d4b5dde 100644 --- a/nemoclaw-blueprint/policies/openclaw-sandbox.yaml +++ b/nemoclaw-blueprint/policies/openclaw-sandbox.yaml @@ -63,6 +63,20 @@ network_policies: rules: - allow: { method: GET, path: "/**" } - allow: { method: POST, path: "/**" } + # sentry.io is a multi-tenant SaaS — any authenticated client can POST + # to ANY Sentry project, not just NemoClaw's. Allowing POST /** turned + # the host into a generic exfiltration channel: a compromised agent + # could ship stack traces, env vars, file contents, etc. to a Sentry + # project controlled by an attacker via the public envelope endpoint + # (https://sentry.io/api//envelope/). Path-pattern + # restrictions cannot fix this because the project ID is part of the + # URL and there is no server-side allowlist of legitimate projects. + # + # Block POST entirely. GET stays allowed because it has no request + # body and is harmless for exfil. Side effect: Claude Code's crash + # telemetry to Sentry is silently dropped — that is the right + # tradeoff for a sandbox whose stated goal is preventing data egress. + # See #1437. - host: sentry.io port: 443 protocol: rest @@ -70,7 +84,6 @@ network_policies: tls: terminate rules: - allow: { method: GET, path: "/**" } - - allow: { method: POST, path: "/**" } binaries: - { path: /usr/local/bin/claude } diff --git a/test/validate-blueprint.test.ts b/test/validate-blueprint.test.ts index ba2ae9522..24368cebd 100644 --- a/test/validate-blueprint.test.ts +++ b/test/validate-blueprint.test.ts @@ -83,4 +83,50 @@ describe("base sandbox policy", () => { it("has 'network_policies'", () => { expect("network_policies" in policy).toBe(true); }); + + // Walk every endpoint in every network_policies entry and return the + // entries whose host matches `hostMatcher`. Used by the regressions below. + type Rule = { allow?: { method?: string; path?: string } }; + type Endpoint = { host?: string; rules?: Rule[] }; + function findEndpoints(hostMatcher: (h: string) => boolean): Endpoint[] { + const out: Endpoint[] = []; + const np = (policy as Record).network_policies; + if (!np || typeof np !== "object") return out; + for (const value of Object.values(np as Record)) { + if (!value || typeof value !== "object") continue; + const endpoints = (value as { endpoints?: unknown }).endpoints; + if (!Array.isArray(endpoints)) continue; + for (const ep of endpoints) { + if (ep && typeof ep === "object" && typeof (ep as Endpoint).host === "string") { + if (hostMatcher((ep as Endpoint).host as string)) { + out.push(ep as Endpoint); + } + } + } + } + return out; + } + + it("regression #1437: sentry.io has no POST allow rule (multi-tenant exfiltration vector)", () => { + const sentryEndpoints = findEndpoints((h) => h === "sentry.io"); + expect(sentryEndpoints.length).toBeGreaterThan(0); // should still appear + for (const ep of sentryEndpoints) { + const rules = Array.isArray(ep.rules) ? ep.rules : []; + const hasPost = rules.some( + (r) => r && r.allow && typeof r.allow.method === "string" && r.allow.method.toUpperCase() === "POST", + ); + expect(hasPost).toBe(false); + } + }); + + it("regression #1437: sentry.io retains GET (harmless, no body for exfil)", () => { + const sentryEndpoints = findEndpoints((h) => h === "sentry.io"); + for (const ep of sentryEndpoints) { + const rules = Array.isArray(ep.rules) ? ep.rules : []; + const hasGet = rules.some( + (r) => r && r.allow && typeof r.allow.method === "string" && r.allow.method.toUpperCase() === "GET", + ); + expect(hasGet).toBe(true); + } + }); }); From 2c20c73060de7dde408a29c40e0395b702e35753 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Wed, 8 Apr 2026 20:43:33 +0000 Subject: [PATCH 2/3] fix(policy): replace POST rules with GET-only for sentry.io (#1437) The path-scoped POST rules (/api/*/envelope/** and /api/*/store/**) do not prevent multi-tenant exfiltration because the Sentry project ID is part of the URL and attacker-controlled. Replace with GET-only: GET has no request body and is bounded by URL length limits, keeping read-only Sentry SDK paths (e.g. DSN config) functional. Co-Authored-By: Claude Opus 4.6 (1M context) --- nemoclaw-blueprint/policies/openclaw-sandbox.yaml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/nemoclaw-blueprint/policies/openclaw-sandbox.yaml b/nemoclaw-blueprint/policies/openclaw-sandbox.yaml index e8bc50021..d9ba15978 100644 --- a/nemoclaw-blueprint/policies/openclaw-sandbox.yaml +++ b/nemoclaw-blueprint/policies/openclaw-sandbox.yaml @@ -96,8 +96,7 @@ network_policies: enforcement: enforce tls: terminate rules: - - allow: { method: POST, path: "/api/*/envelope/**" } - - allow: { method: POST, path: "/api/*/store/**" } + - allow: { method: GET, path: "/**" } binaries: - { path: /usr/local/bin/claude } From bdacd2cc3575feb946c400c8c6056a761bdb1148 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Wed, 8 Apr 2026 18:56:47 -0700 Subject: [PATCH 3/3] fix(test): restore regression tests for sentry.io POST block (#1437) The original contributor commit (1fd6e07) included regression tests that assert no POST rules exist for sentry.io in the sandbox policy. These were accidentally dropped during merge conflict resolution at 2c20c73. Restore them verbatim. Signed-off-by: Aaron Erickson --- test/validate-blueprint.test.ts | 46 +++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/test/validate-blueprint.test.ts b/test/validate-blueprint.test.ts index d178e5b38..28478665d 100644 --- a/test/validate-blueprint.test.ts +++ b/test/validate-blueprint.test.ts @@ -142,4 +142,50 @@ describe("base sandbox policy", () => { } expect(missingHosts).toEqual([]); }); + + // Walk every endpoint in every network_policies entry and return the + // entries whose host matches `hostMatcher`. Used by the regressions below. + type Rule = { allow?: { method?: string; path?: string } }; + type Endpoint = { host?: string; rules?: Rule[] }; + function findEndpoints(hostMatcher: (h: string) => boolean): Endpoint[] { + const out: Endpoint[] = []; + const np = (policy as Record).network_policies; + if (!np || typeof np !== "object") return out; + for (const value of Object.values(np as Record)) { + if (!value || typeof value !== "object") continue; + const endpoints = (value as { endpoints?: unknown }).endpoints; + if (!Array.isArray(endpoints)) continue; + for (const ep of endpoints) { + if (ep && typeof ep === "object" && typeof (ep as Endpoint).host === "string") { + if (hostMatcher((ep as Endpoint).host as string)) { + out.push(ep as Endpoint); + } + } + } + } + return out; + } + + it("regression #1437: sentry.io has no POST allow rule (multi-tenant exfiltration vector)", () => { + const sentryEndpoints = findEndpoints((h) => h === "sentry.io"); + expect(sentryEndpoints.length).toBeGreaterThan(0); // should still appear + for (const ep of sentryEndpoints) { + const rules = Array.isArray(ep.rules) ? ep.rules : []; + const hasPost = rules.some( + (r) => r && r.allow && typeof r.allow.method === "string" && r.allow.method.toUpperCase() === "POST", + ); + expect(hasPost).toBe(false); + } + }); + + it("regression #1437: sentry.io retains GET (harmless, no body for exfil)", () => { + const sentryEndpoints = findEndpoints((h) => h === "sentry.io"); + for (const ep of sentryEndpoints) { + const rules = Array.isArray(ep.rules) ? ep.rules : []; + const hasGet = rules.some( + (r) => r && r.allow && typeof r.allow.method === "string" && r.allow.method.toUpperCase() === "GET", + ); + expect(hasGet).toBe(true); + } + }); });