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); + } + }); });